12 Commits

Author SHA1 Message Date
talksik 79824baaa6 change price ids 2026-07-02 19:19:06 -07:00
Arjun Patel d60b948262 chore: bump version to v1.9.0 (#303) 2026-06-21 11:48:33 -07:00
Arjun Patel fec7f5d6d8 Add onboarding wizard for teaching the record key (#301)
* Add first-run onboarding wizard for recording mechanics

Introduce a minimal, skippable first-run wizard that teaches the record
key. On first authenticated launch it renders as a full-screen takeover
(gated above PusherProvider) so the stream/compose keyboard handlers are
not mounted to compete for the same keys.

Steps: welcome, hold ` to record, tap V to switch audio/video, tap ` to
toggle recording, mic/camera permission priming, and a recap. The lessons
detect the real gestures and never block — Esc or the Skip control
dismisses at any point.

Reuses existing pieces: VideoAudioToggle, useMediaDevices().requestLabels
for permission priming, KeyHint, and isTypingTarget. Extracts the shared
HOLD_THRESHOLD_MS into lib/constants and reuses it in the compose overlay.

Resolves #259

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9AHySdLb1zfZAEm6SuaQP

* nits

* light mode support for onboarding

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-21 11:45:14 -07:00
Arjun Patel d14cec5885 feat: add warm sand theme colors and light / dark mode toggle (#302) 2026-06-21 10:56:59 -07:00
Arjun Patel 3d80ac2993 feat: increase feature parity between mobile and desktop (#298)
* mobile: add invitations, network creation, and settings (parity phase 1)

Surface backend capabilities that already existed in the mobile API client
but had no UI:

- NetworkListScreen now lists pending invitations with an Accept action and
  a header "+" to create a network; empty state offers creation instead of
  pointing users to desktop.
- New CreateNetworkSheet and use-invitations hooks (accept invite, create
  network) following the existing react-query patterns.
- SettingsScreen replaces its placeholder with an email-notifications toggle
  (optimistic, mirrors desktop), app version, and sign out.
- Wire the previously-unreachable Settings row into the Drawer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: network member management and avatars (parity phase 2)

- New NetworkSettingsScreen (reachable from the stream-list header) lists
  members with admin remove, an invite-by-email sheet, and pending
  invitations with revoke — backed by new use-member-management hooks.
- Avatars: add avatar_object_id to HumanSchema, uploadAvatar/deleteAvatar/
  getAvatarDownloadUrl client methods (raw PUT via expo-file-system), a
  use-avatar-url hook, and image rendering in the shared Avatar component.
  AccountScreen gains a tap-to-change profile picture via expo-image-picker.
- auth-store gains refreshUser to pick up avatar changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: task particles — view, edit, and compose (parity phase 3)

Bring the task particle to parity with desktop's richer model:

- Replace the thin `quest` schema with desktop's `task` model
  (ChecklistItem, TaskProperties: title/notes/checklist/assigned_to/done)
  in the discriminated union, the Firestore converter, and consumers
  (StreamCard, FallbackParticleView).
- New TaskParticleView renders an editable card (round done checkbox,
  title, notes, checklist with add/toggle/edit/remove, assignee chips)
  persisting each edit to Firestore; an 8s dwell auto-advances and
  field focus suspends playback. Wired into StreamView's render switch.
- Compose: a task button in the ComposeDock opens a TaskComposeSheet
  (createTaskParticle helper). Gated off in the new-stream flow, where a
  stream's first particle must be text or media.

Note: particles are written client-side to Firestore, matching desktop;
Orion's REST validator still only accepts `quest`, which is a pre-existing
inconsistency to reconcile backend-side separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: paper and file particle views (parity phase 4)

- Extract the shared markdown renderer/theme out of TextParticleView into
  a reusable MarkdownBody component (DRY).
- PaperParticleView renders desktop-authored documents (title + markdown)
  with a length-based dwell.
- FileParticleView shows name/size and a Download action that opens a
  signed URL via the OS.
- Both wired into StreamView's render switch; FallbackParticleView is now a
  true catch-all for unknown/folder types only.

Deferred (documented for a follow-up phase): composing papers/files from
mobile, particle attachments + lightbox, and link previews in text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: billing & usage in network settings (parity phase 5)

Surface the network plan, daily usage, and Stripe management — all backed
by client methods that already existed. New use-billing hooks and a
BillingSection (mirroring desktop): every member sees the plan + usage
summary; admins get cadence selection + "Upgrade to Pro" (checkout) and
"Manage subscription" (portal), opening Stripe in the system browser.
Added to NetworkSettingsScreen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* mobile: let users dismiss the keyboard from a task card

Focusing a task field opened the keyboard with no way out — it covered the
card and the stream's tap-to-advance zones. Now:

- A "Done" pill appears at the card's top-right while editing (reusing the
  existing `editing` flag) and calls Keyboard.dismiss(); the title row
  reserves space so the pill never overlaps a long title.
- The card ScrollView gains keyboardDismissMode (interactive on iOS, on-drag
  on Android) so dragging the card also dismisses the keyboard.

Dismissing blurs the active field, which flips `editing` off and resumes the
dwell timer and tap navigation automatically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV

* decrease clutter in stream-view

* format

* format

* consolidate avatar

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-21 10:38:16 -07:00
Arjun Patel 428f9ea1c9 Add immersive focus mode for task editing (#300)
* feat(desktop): immersive focus mode for editing tasks

Editing a task in the stream was ambiguous: nothing signalled that you
were editing, playback chrome stayed live, Escape exited the whole stream,
and arrow keys could navigate to another particle mid-edit.

Focusing any field now enters a focus mode: the surrounding stream chrome
dims and blurs while the task card lifts above it with a soft ring, so the
task you're editing is unmistakable. Escape (or clicking outside the card)
leaves the editor and resumes playback from where it paused instead of
exiting the stream, and arrow/list keys no longer pull focus to another
particle while editing.

Text particles already get this via their full-screen edit overlay, so the
two editing flows are now consistent.

Addresses #286

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDWBJjERKk8zUmh6bcKWSM

* refactor(desktop): make task focus mode self-contained

Handle editing focus entirely within TaskParticleView instead of threading
editing state up to StreamView and special-casing the navigation hook.

While editing, the card now registers a capture-phase key listener — the
same pattern the app's other focus overlays use — that consumes Escape
(blurring the field, which flushes the draft and resumes playback) and
swallows stream-navigation keys so they can't pull focus to another
particle. An "Esc to finish" hint shows while editing.

This restores use-stream-navigation-keys to its original form and reduces
the StreamView change to a single `immersive` prop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BDWBJjERKk8zUmh6bcKWSM

* fix style of keyhint

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-21 10:20:26 -07:00
Arjun Patel f2f8602263 fix: route clipboard image pastes to the attachment strip (#295)
* fix: route clipboard image pastes to the attachment strip

Pasting an image while the markdown editor was focused left it stuck on
ProseMirror's inline "uploading" placeholder instead of going to the
attachment strip. The capture-phase paste interceptor bailed before it
could redirect the file because:

- pasted images usually surface only through `clipboardData.items`
  (`getAsFile`), with `clipboardData.files` left empty, and
- image pastes often advertise an *empty* `text/plain` entry, which the
  old `types.includes('text/plain')` check mistook for a text paste.

Read files from `items` (falling back to `files`), and gate on the actual
text payload rather than the advertised type, so real text pastes
(Excel/Word renditions) still paste as text while pure image pastes
become attachments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtNQeBpkDJPC9obiB25pV6

* fix: disable Milkdown ImageBlock so paste never strands an upload node

Routing pasted images to the attachment strip wasn't enough — the editor's
ImageBlock feature still handled the paste itself and left a stuck "upload
in progress" node, since there's no public upload URL for it to resolve to.

Turn the feature off entirely. It's the root cause: it's the only thing
that registers paste/drop-to-upload. Image *links* still render through the
base commonmark schema (a plain <img>), and pasted image files continue to
go to the attachment strip via use-file-input. Drop the now-dead image-block
upload/chrome CSS and keep a simple inline-image style.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtNQeBpkDJPC9obiB25pV6

* fix: decline dropped files in the editor so they don't inline

Dragging an image onto the markdown editor attached it to the strip (the
drop zone fired) but ProseMirror also handled the same drop, parsing the
drag's HTML into an inline image node with a blob:/localhost src. That URL
then leaked into the markdown and surfaced as a stray link-preview chip.

Add a ProseMirror handleDrop that declines drops carrying files, so the
editor stops inlining them while the drop zone still routes them to the
attachment strip. Extract the paste/drag file extraction into a shared
transferFiles helper used by both paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtNQeBpkDJPC9obiB25pV6

* nit

* format

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-21 09:15:21 -07:00
Arjun Patel 661577ecd2 fix: remove enforced recording limit (#297) 2026-06-21 08:33:14 -07:00
Arjun Patel c6ce1bdc65 fix(desktop): keep esc inside compose instead of exiting the stream (#294)
While recording or reviewing a compose, Escape both cancelled the compose
and exited the stream. The stream's window-level navigation handler skips
keys when an input is focused (isTypingTarget), which is why the text path
was unaffected, but recording/reviewing have no focused input so Escape
leaked through to the exit handler.

Fix it at the compose layer rather than teaching the navigation pipe about
compose: the compose key handler now consumes (preventDefault +
stopPropagation) any key it handles and listens in the capture phase, so it
reliably wins over the stream's bubble-phase navigation/action handlers
regardless of listener registration order. Pure pause states (hold-space)
are untouched, so Escape still exits the stream there.

This leaves the typing/task/configuring paths equivalent (their onCancel is
the same cancel() the handler invokes) while ⌘+Enter / ⌘+M still fall
through to the text editor.


Claude-Session: https://claude.ai/code/session_01DapjFX1MhYPZeJv4s56K5L

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-21 08:32:58 -07:00
Arjun Patel bfe53b46cf chore: bump desktop version to v1.8.0 (#293) 2026-06-20 17:27:38 -07:00
Arjun Patel 129e4772dd fix: ui papercuts (#292)
* fix: avatar showing blank when switching

* fix: sidebar padding glitchy

Long markdown content would make each chat row expand and remove the padding in the sidebar

* ignore emacs project.el

* nit
2026-06-20 17:22:45 -07:00
Arjun Patel 92c12ad0bd feat: add limits to recordings on destop (#291)
* feat: add limits to recordings on destop

* code review
2026-06-20 17:22:22 -07:00
100 changed files with 3908 additions and 764 deletions
+1
View File
@@ -5,3 +5,4 @@ build/
compile_commands.json compile_commands.json
CMakeLists.txt.user CMakeLists.txt.user
tags tags
project.el
+2 -2
View File
@@ -69,9 +69,9 @@ spec:
name: shared-secrets name: shared-secrets
key: STRIPE_WEBHOOK_SECRET key: STRIPE_WEBHOOK_SECRET
- name: "STRIPE_PRICE_PRO_MONTHLY" - name: "STRIPE_PRICE_PRO_MONTHLY"
value: "price_1TMBElJu6RWBXAm2pPthUoh6" value: "price_1ToweOJu6RWBXAm2w2wTo8VL"
- name: "STRIPE_PRICE_PRO_ANNUAL" - name: "STRIPE_PRICE_PRO_ANNUAL"
value: "price_1TMBElJu6RWBXAm2TOpzdXk5" value: "price_1TowexJu6RWBXAm2G9M0WJBK"
- name: "BILLING_SUCCESS_URL" - name: "BILLING_SUCCESS_URL"
value: "llink://billing/success" value: "llink://billing/success"
- name: "BILLING_CANCEL_URL" - name: "BILLING_CANCEL_URL"
+17 -1
View File
@@ -1,8 +1,24 @@
<!doctype html> <!doctype html>
<html class="dark"> <html>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>llink</title> <title>llink</title>
<script>
// Apply the stored theme before first paint to avoid a flash. Mirrors
// the logic in stores/theme-store.ts; defaults to dark.
try {
var m = localStorage.getItem('llink:theme');
var dark =
m === 'light'
? false
: m === 'system'
? matchMedia('(prefers-color-scheme: dark)').matches
: true;
if (dark) document.documentElement.classList.add('dark');
} catch (e) {
document.documentElement.classList.add('dark');
}
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "Flowy.llink", "name": "Flowy.llink",
"productName": "Flowy.llink", "productName": "Flowy.llink",
"version": "1.7.1", "version": "1.9.0",
"description": "Flowy.llink is a video messaging app for teams", "description": "Flowy.llink is a video messaging app for teams",
"main": ".vite/build/main.js", "main": ".vite/build/main.js",
"private": true, "private": true,
+10
View File
@@ -23,12 +23,18 @@ import {
import { SoundEffectsProvider } from '@/lib/sound-effects/sound-effects-provider'; import { SoundEffectsProvider } from '@/lib/sound-effects/sound-effects-provider';
import { platform } from '@/lib/platform'; import { platform } from '@/lib/platform';
import { InAppAutoplayCard } from '@/components/in-app-autoplay-card'; import { InAppAutoplayCard } from '@/components/in-app-autoplay-card';
import { useOnboardingStore } from '@/stores/onboarding-store';
import { OnboardingOverlay } from '@/features/onboarding/onboarding-overlay';
const queryClient = createQueryClient(); const queryClient = createQueryClient();
const App = () => { const App = () => {
const status = useAuthStore((s) => s.status); const status = useAuthStore((s) => s.status);
const restoreSession = useAuthStore((s) => s.restoreSession); const restoreSession = useAuthStore((s) => s.restoreSession);
const hasCompletedOnboarding = useOnboardingStore(
(s) => s.hasCompletedOnboarding,
);
const markOnboardingComplete = useOnboardingStore((s) => s.markComplete);
useEffect(() => { useEffect(() => {
restoreSession(); restoreSession();
@@ -46,6 +52,10 @@ const App = () => {
return <LoginPage />; return <LoginPage />;
} }
if (!hasCompletedOnboarding) {
return <OnboardingOverlay onComplete={markOnboardingComplete} />;
}
return ( return (
<PusherProvider> <PusherProvider>
<AuthenticatedApp /> <AuthenticatedApp />
@@ -29,14 +29,14 @@ export function ComposingIndicator({
return ( return (
<div <div
key={u.humanId} key={u.humanId}
className="flex rotate-180 items-center gap-1.5 rounded-full bg-white/10 px-2 py-1 backdrop-blur-sm" className="bg-card/70 border-border flex rotate-180 items-center gap-1.5 rounded-full border px-2 py-1 backdrop-blur-sm"
> >
<span className="flex gap-0.5"> <span className="flex gap-0.5">
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:0ms]" /> <span className="bg-muted-foreground size-1 animate-bounce rounded-full [animation-delay:0ms]" />
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:150ms]" /> <span className="bg-muted-foreground size-1 animate-bounce rounded-full [animation-delay:150ms]" />
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:300ms]" /> <span className="bg-muted-foreground size-1 animate-bounce rounded-full [animation-delay:300ms]" />
</span> </span>
<span className="whitespace-nowrap text-[10px] text-white/50"> <span className="text-muted-foreground whitespace-nowrap text-[10px]">
{displayName} {modeLabel} {displayName} {modeLabel}
</span> </span>
</div> </div>
@@ -38,23 +38,23 @@ export function ConfirmDestructiveOverlay({
return createPortal( return createPortal(
<div className="fixed inset-0 z-[100]"> <div className="fixed inset-0 z-[100]">
<div <div
className="absolute inset-0 bg-black/60 backdrop-blur-sm" className="absolute inset-0 bg-scrim/60 backdrop-blur-sm"
onClick={onClose} onClick={onClose}
/> />
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl"> <div className="bg-popover/95 text-popover-foreground border-border absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border p-6 shadow-2xl backdrop-blur-xl">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">{title}</h2> <h2 className="text-sm font-semibold">{title}</h2>
<KeyHint <KeyHint
keys="Esc" keys="Esc"
onClick={onClose} onClick={onClose}
title="Close (or press Esc)" title="Close (or press Esc)"
className="text-xs text-white/30" className="text-muted-foreground text-xs"
> >
to close to close
</KeyHint> </KeyHint>
</div> </div>
<div className="text-sm text-white/60">{description}</div> <div className="text-muted-foreground text-sm">{description}</div>
<div className="mt-5 flex items-center justify-end gap-2"> <div className="mt-5 flex items-center justify-end gap-2">
<Button <Button
+1 -1
View File
@@ -29,7 +29,7 @@ export function HumanAvatar({
const url = useAvatarUrl(avatarObjectId); const url = useAvatarUrl(avatarObjectId);
return ( return (
<Avatar {...props}> <Avatar {...props}>
{url && <AvatarImage src={url} alt={initials} />} <AvatarImage src={url} alt={initials} />
<AvatarFallback className={fallbackClassName}>{initials}</AvatarFallback> <AvatarFallback className={fallbackClassName}>{initials}</AvatarFallback>
</Avatar> </Avatar>
); );
+4 -3
View File
@@ -2,9 +2,10 @@ import { Fragment } from 'react';
import { Kbd } from '@/components/ui/kbd'; import { Kbd } from '@/components/ui/kbd';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/** Dark-overlay chip restyle of the design-system Kbd, kept in one place. */ /** Chip restyle of the design-system Kbd, kept in one place. Tints to the
surrounding text color so it reads on themed panels and over media alike. */
const chipClass = const chipClass =
'rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs font-normal text-current'; 'rounded bg-current/10 px-1.5 py-0.5 font-mono text-xs font-normal text-current';
interface KeyHintProps { interface KeyHintProps {
/** Key chip(s): "Esc" or ["Esc", "Q"]. */ /** Key chip(s): "Esc" or ["Esc", "Q"]. */
@@ -68,7 +69,7 @@ export function KeyHint({
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
title={title} title={title}
className={cn( className={cn(
'cursor-pointer rounded transition-colors hover:text-white/80', 'cursor-pointer rounded transition-opacity hover:opacity-80',
className, className,
)} )}
{...rest} {...rest}
@@ -48,19 +48,19 @@ export function KeybindingsOverlay({
<div className="fixed inset-0 z-[100]"> <div className="fixed inset-0 z-[100]">
{/* Backdrop */} {/* Backdrop */}
<div <div
className="absolute inset-0 bg-black/60 backdrop-blur-sm" className="absolute inset-0 bg-scrim/60 backdrop-blur-sm"
onClick={onClose} onClick={onClose}
/> />
{/* Panel */} {/* Panel */}
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl"> <div className="bg-popover/95 text-popover-foreground border-border absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border p-6 shadow-2xl backdrop-blur-xl">
<div className="mb-5 flex items-center justify-between"> <div className="mb-5 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">{title}</h2> <h2 className="text-sm font-semibold">{title}</h2>
<KeyHint <KeyHint
keys={['Esc', '?']} keys={['Esc', '?']}
separator="or" separator="or"
onClick={onClose} onClick={onClose}
title="Close (or press Esc / ?)" title="Close (or press Esc / ?)"
className="text-xs text-white/30" className="text-muted-foreground text-xs"
> >
to close to close
</KeyHint> </KeyHint>
@@ -68,21 +68,19 @@ export function KeybindingsOverlay({
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{groups.map((group) => ( {groups.map((group) => (
<section key={group.label}> <section key={group.label}>
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30"> <h3 className="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
{group.label} {group.label}
</h3> </h3>
<ul className="flex flex-col gap-1"> <ul className="flex flex-col gap-1">
{group.bindings.map((binding) => ( {group.bindings.map((binding) => (
<li <li
key={binding.description} key={binding.description}
className="flex items-center justify-between border-b border-white/5 pb-1 last:border-0 last:pb-0" className="border-border flex items-center justify-between border-b pb-1 last:border-0 last:pb-0"
> >
<span className="text-sm text-white/70"> <span className="text-sm">{binding.description}</span>
{binding.description}
</span>
<KeyHint <KeyHint
keys={binding.keys} keys={binding.keys}
className="flex items-center gap-1 text-white/60" className="text-muted-foreground flex items-center gap-1"
/> />
</li> </li>
))} ))}
+11 -11
View File
@@ -22,7 +22,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
return ( return (
<div <div
className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md" className="bg-card text-card-foreground border-border max-w-sm overflow-hidden rounded-2xl border"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleOpen(e); handleOpen(e);
@@ -39,7 +39,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
/> />
)} )}
<div className="flex flex-col gap-1 p-3"> <div className="flex flex-col gap-1 p-3">
<div className="flex items-center gap-1.5 text-xs text-white/50"> <div className="text-muted-foreground flex items-center gap-1.5 text-xs">
{metadata.favicon ? ( {metadata.favicon ? (
<img <img
src={metadata.favicon} src={metadata.favicon}
@@ -57,12 +57,12 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
<span className="truncate">{metadata.domain}</span> <span className="truncate">{metadata.domain}</span>
</div> </div>
{metadata.title && ( {metadata.title && (
<p className="truncate text-sm font-semibold leading-snug text-white"> <p className="truncate text-sm font-semibold leading-snug">
{metadata.title} {metadata.title}
</p> </p>
)} )}
{metadata.description && ( {metadata.description && (
<p className="line-clamp-2 text-xs leading-relaxed text-white/70"> <p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{metadata.description} {metadata.description}
</p> </p>
)} )}
@@ -71,7 +71,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
<Button <Button
variant="ghost" variant="ghost"
size="xs" size="xs"
className="text-white/70 hover:bg-white/10 hover:text-white" className="text-muted-foreground"
onClick={handleOpen} onClick={handleOpen}
> >
<ExternalLink data-icon="inline-start" /> <ExternalLink data-icon="inline-start" />
@@ -80,7 +80,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
<Button <Button
variant="ghost" variant="ghost"
size="xs" size="xs"
className="text-white/70 hover:bg-white/10 hover:text-white" className="text-muted-foreground"
onClick={handleCopy} onClick={handleCopy}
> >
<Copy data-icon="inline-start" /> <Copy data-icon="inline-start" />
@@ -111,12 +111,12 @@ export function LinkPreviewCardFallback({ url }: { url: string }) {
export function LinkPreviewCardSkeleton() { export function LinkPreviewCardSkeleton() {
return ( return (
<div className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md"> <div className="bg-card border-border max-w-sm overflow-hidden rounded-2xl border">
<Skeleton className="h-32 w-full rounded-none bg-white/5" /> <Skeleton className="h-32 w-full rounded-none" />
<div className="flex flex-col gap-2 p-3"> <div className="flex flex-col gap-2 p-3">
<Skeleton className="h-3 w-24 bg-white/10" /> <Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-48 bg-white/10" /> <Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-full bg-white/10" /> <Skeleton className="h-3 w-full" />
</div> </div>
</div> </div>
); );
@@ -34,13 +34,13 @@ export function ScreenSourcePicker({
const windows = sources.filter((s) => s.id.startsWith('window:')); const windows = sources.filter((s) => s.id.startsWith('window:'));
return ( return (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90"> <div className="bg-scrim/80 absolute inset-0 z-50 flex items-center justify-center">
<div className="mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-zinc-900 shadow-xl"> <div className="bg-popover text-popover-foreground mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg shadow-xl">
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4"> <div className="border-border flex items-center justify-between border-b px-5 py-4">
<h2 className="text-base font-medium text-zinc-100">{title}</h2> <h2 className="text-base font-medium">{title}</h2>
<button <button
onClick={onCancel} onClick={onCancel}
className="text-zinc-400 hover:text-zinc-200" className="text-muted-foreground hover:text-foreground"
> >
</button> </button>
@@ -48,7 +48,7 @@ export function ScreenSourcePicker({
<div className="flex-1 overflow-y-auto px-5 py-4"> <div className="flex-1 overflow-y-auto px-5 py-4">
{loading ? ( {loading ? (
<p className="text-center text-sm text-zinc-400"> <p className="text-muted-foreground text-center text-sm">
Loading sources... Loading sources...
</p> </p>
) : ( ) : (
@@ -73,17 +73,17 @@ export function ScreenSourcePicker({
)} )}
</div> </div>
<div className="flex justify-end gap-2 border-t border-zinc-700 px-5 py-3"> <div className="border-border flex justify-end gap-2 border-t px-5 py-3">
<button <button
onClick={onCancel} onClick={onCancel}
className="rounded-md px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-800" className="hover:bg-accent text-muted-foreground rounded-md px-4 py-2 text-sm"
> >
Cancel Cancel
</button> </button>
<button <button
disabled={!selectedId} disabled={!selectedId}
onClick={() => selectedId && onSelect(selectedId)} onClick={() => selectedId && onSelect(selectedId)}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-40 disabled:hover:bg-blue-600" className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-40"
> >
{confirmLabel} {confirmLabel}
</button> </button>
@@ -106,7 +106,7 @@ function SourceSection({
}) { }) {
return ( return (
<div className="mb-4"> <div className="mb-4">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-zinc-400"> <h3 className="text-muted-foreground mb-2 text-xs font-medium uppercase tracking-wide">
{title} {title}
</h3> </h3>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
@@ -116,8 +116,8 @@ function SourceSection({
onClick={() => onSelect(source.id)} onClick={() => onSelect(source.id)}
className={`overflow-hidden rounded-lg border-2 text-left transition-colors ${ className={`overflow-hidden rounded-lg border-2 text-left transition-colors ${
selectedId === source.id selectedId === source.id
? 'border-blue-500 bg-zinc-800' ? 'border-primary bg-accent'
: 'border-transparent bg-zinc-800/50 hover:border-zinc-600' : 'border-transparent bg-muted hover:border-border'
}`} }`}
> >
<img <img
@@ -125,7 +125,7 @@ function SourceSection({
alt={source.name} alt={source.name}
className="aspect-video w-full object-cover" className="aspect-video w-full object-cover"
/> />
<p className="truncate px-2 py-1.5 text-xs text-zinc-300"> <p className="text-muted-foreground truncate px-2 py-1.5 text-xs">
{source.name} {source.name}
</p> </p>
</button> </button>
+1 -1
View File
@@ -39,7 +39,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50', 'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-scrim/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50',
className, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -47,7 +47,7 @@ function Slider({
<SliderPrimitive.Thumb <SliderPrimitive.Thumb
data-slot="slider-thumb" data-slot="slider-thumb"
key={index} key={index}
className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50" className="border-ring bg-background ring-ring/50 relative block size-3 shrink-0 select-none rounded-full border transition-[color,box-shadow] after:absolute after:-inset-2 hover:ring-3 focus-visible:outline-hidden focus-visible:ring-3 active:ring-3 disabled:pointer-events-none disabled:opacity-50"
/> />
))} ))}
</SliderPrimitive.Root> </SliderPrimitive.Root>
+3 -1
View File
@@ -6,11 +6,13 @@ import {
OctagonXIcon, OctagonXIcon,
Loader2Icon, Loader2Icon,
} from 'lucide-react'; } from 'lucide-react';
import { useThemeStore } from '@/stores/theme-store';
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const isDark = useThemeStore((s) => s.isDark);
return ( return (
<Sonner <Sonner
theme="dark" theme={isDark ? 'dark' : 'light'}
className="toaster group" className="toaster group"
icons={{ icons={{
success: <CircleCheckIcon className="size-4" />, success: <CircleCheckIcon className="size-4" />,
@@ -21,7 +21,7 @@ export function WindowControls() {
size="icon-sm" size="icon-sm"
onClick={() => platform.window.minimize()} onClick={() => platform.window.minimize()}
aria-label="Minimize" aria-label="Minimize"
className="dark:hover:bg-white/10 rounded text-white/50" className="text-muted-foreground rounded"
> >
<Minus /> <Minus />
</Button> </Button>
@@ -34,7 +34,7 @@ export function WindowControls() {
size="icon-sm" size="icon-sm"
onClick={() => platform.window.maximize()} onClick={() => platform.window.maximize()}
aria-label={isMaximized ? 'Restore' : 'Maximize'} aria-label={isMaximized ? 'Restore' : 'Maximize'}
className="dark:hover:bg-white/10 rounded text-white/50" className="text-muted-foreground rounded"
> >
{isMaximized ? <Copy /> : <Square />} {isMaximized ? <Copy /> : <Square />}
</Button> </Button>
@@ -47,7 +47,7 @@ export function WindowControls() {
size="icon-sm" size="icon-sm"
onClick={() => platform.window.close()} onClick={() => platform.window.close()}
aria-label="Close" aria-label="Close"
className="hover:text-destructive dark:hover:bg-white/10 rounded text-white/50" className="hover:text-destructive text-muted-foreground rounded"
> >
<X /> <X />
</Button> </Button>
@@ -172,7 +172,7 @@ export function AttachmentLightbox({
}} }}
> >
<DialogPrimitive.Portal> <DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-black/90 backdrop-blur-sm duration-100" /> <DialogPrimitive.Overlay className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-scrim/90 fixed inset-0 z-50 backdrop-blur-sm duration-100" />
<DialogPrimitive.Content <DialogPrimitive.Content
aria-describedby={undefined} aria-describedby={undefined}
className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 flex items-center justify-center p-16 outline-none duration-100" className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 flex items-center justify-center p-16 outline-none duration-100"
@@ -62,7 +62,7 @@ function AttachmentThumbnail({
tabIndex={previewable ? 0 : undefined} tabIndex={previewable ? 0 : undefined}
onClick={previewable && onPreview ? onPreview : undefined} onClick={previewable && onPreview ? onPreview : undefined}
className={cn( className={cn(
'group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10', 'bg-muted group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg',
previewable && 'cursor-pointer', previewable && 'cursor-pointer',
isError && 'ring-1 ring-red-400/50', isError && 'ring-1 ring-red-400/50',
)} )}
@@ -75,19 +75,19 @@ function AttachmentThumbnail({
/> />
) : ( ) : (
<div className="flex flex-col items-center gap-0.5 px-1"> <div className="flex flex-col items-center gap-0.5 px-1">
<FileIcon className="size-5 text-white/60" /> <FileIcon className="text-muted-foreground size-5" />
<span className="max-w-full truncate text-[9px] text-white/50"> <span className="text-muted-foreground max-w-full truncate text-[9px]">
{attachment.file.name} {attachment.file.name}
</span> </span>
<span className="text-[9px] text-white/40"> <span className="text-muted-foreground text-[9px]">
{formatFileSize(attachment.file.size)} {formatFileSize(attachment.file.size)}
</span> </span>
</div> </div>
)} )}
{isUploading && ( {isUploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50"> <div className="bg-scrim/50 absolute inset-0 flex items-center justify-center">
<Loader2 className="size-4 animate-spin text-white/70" /> <Loader2 className="size-4 animate-spin text-white" />
</div> </div>
)} )}
@@ -97,7 +97,7 @@ function AttachmentThumbnail({
e.stopPropagation(); e.stopPropagation();
onRemove(); onRemove();
}} }}
className="absolute right-0.5 top-0.5 hidden rounded-full bg-black/70 p-0.5 text-white/70 hover:text-white group-hover:block" className="bg-scrim/70 absolute right-0.5 top-0.5 hidden rounded-full p-0.5 text-white/80 hover:text-white group-hover:block"
> >
<X className="size-3" /> <X className="size-3" />
</button> </button>
@@ -108,9 +108,9 @@ function AttachmentThumbnail({
function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) { function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
if (entry.isLoading) { if (entry.isLoading) {
return ( return (
<div className="flex h-16 w-28 shrink-0 flex-col gap-1.5 rounded-lg bg-white/10 p-2"> <div className="bg-muted flex h-16 w-28 shrink-0 flex-col gap-1.5 rounded-lg p-2">
<Skeleton className="h-2 w-16 bg-white/10" /> <Skeleton className="h-2 w-16" />
<Skeleton className="h-3 w-24 bg-white/10" /> <Skeleton className="h-3 w-24" />
</div> </div>
); );
} }
@@ -124,9 +124,9 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
<button <button
type="button" type="button"
onClick={() => platform.link.openExternal(metadata?.url ?? entry.url)} onClick={() => platform.link.openExternal(metadata?.url ?? entry.url)}
className="flex h-16 w-28 shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-lg bg-white/10 px-2 py-1.5 text-left transition-colors hover:bg-white/15" className="bg-muted hover:bg-accent flex h-16 w-28 shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-lg px-2 py-1.5 text-left transition-colors"
> >
<div className="flex items-center gap-1 text-[10px] text-white/40"> <div className="text-muted-foreground flex items-center gap-1 text-[10px]">
{metadata?.favicon ? ( {metadata?.favicon ? (
<img <img
src={metadata.favicon} src={metadata.favicon}
@@ -142,7 +142,7 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
<span className="truncate">{domain}</span> <span className="truncate">{domain}</span>
</div> </div>
{title && ( {title && (
<p className="line-clamp-2 text-[11px] font-medium leading-tight text-white/80"> <p className="text-foreground line-clamp-2 text-[11px] font-medium leading-tight">
{title} {title}
</p> </p>
)} )}
@@ -197,7 +197,7 @@ export function AttachmentStrip({
e.stopPropagation(); e.stopPropagation();
onAddClick(); onAddClick();
}} }}
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-lg border border-dashed border-white/20 text-white/40 transition-colors hover:border-white/40 hover:text-white/60" className="border-border text-muted-foreground hover:border-foreground/40 hover:text-foreground flex h-16 w-16 shrink-0 items-center justify-center rounded-lg border border-dashed transition-colors"
> >
<Plus className="size-5" /> <Plus className="size-5" />
</button> </button>
@@ -29,7 +29,11 @@ import { useMediaDevices } from '@/hooks/use-media-devices';
import { resolveEffectiveDeviceId } from '@/hooks/use-effective-device-id'; import { resolveEffectiveDeviceId } from '@/hooks/use-effective-device-id';
import { useFileInput } from '@/hooks/use-file-input'; import { useFileInput } from '@/hooks/use-file-input';
import { createImageThumbnail } from '@/lib/image-thumbnail'; import { createImageThumbnail } from '@/lib/image-thumbnail';
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from '@/lib/constants'; import {
HOLD_THRESHOLD_MS,
MAX_ATTACHMENT_SIZE_BYTES,
MAX_ATTACHMENTS,
} from '@/lib/constants';
import type { PendingAttachment } from '@/features/compose/attachment-strip'; import type { PendingAttachment } from '@/features/compose/attachment-strip';
import { useSuspendPlayback } from '@/hooks/use-suspend-playback'; import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { useComposeIntentStore } from '@/stores/compose-intent-store'; import { useComposeIntentStore } from '@/stores/compose-intent-store';
@@ -59,8 +63,6 @@ interface ComposeOverlayProps {
onParticleCreated?: (particleId: string) => void; onParticleCreated?: (particleId: string) => void;
} }
const HOLD_THRESHOLD_MS = 250;
/** /**
* Self-contained compose overlay. Each consumer renders its own instance * Self-contained compose overlay. Each consumer renders its own instance
* with props that determine the mode (new stream vs. reply). * with props that determine the mode (new stream vs. reply).
@@ -595,6 +597,16 @@ export function ComposeOverlay({
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
const currentStep = stepRef.current; const currentStep = stepRef.current;
// Consume a key compose handles so it never reaches the stream's
// window-level navigation/action handlers. Without this, e.g. Escape
// while recording or reviewing would both cancel compose and exit the
// stream. Listening in the capture phase (see below) guarantees compose
// sees the key before those handlers regardless of registration order.
const consume = () => {
e.preventDefault();
e.stopPropagation();
};
if ( if (
currentStep === 'typing' || currentStep === 'typing' ||
currentStep === 'task' || currentStep === 'task' ||
@@ -602,7 +614,7 @@ export function ComposeOverlay({
currentStep === 'picking' currentStep === 'picking'
) { ) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault(); consume();
cancel(); cancel();
} }
return; return;
@@ -620,19 +632,19 @@ export function ComposeOverlay({
switch (currentStep) { switch (currentStep) {
case 'idle': { case 'idle': {
if (e.key === '`' && !e.repeat) { if (e.key === '`' && !e.repeat) {
e.preventDefault(); consume();
handleRecordIntent(); handleRecordIntent();
} else if (e.key === 's' || e.key === 'S') { } else if (e.key === 's' || e.key === 'S') {
e.preventDefault(); consume();
if (!guardIdle()) break; if (!guardIdle()) break;
if (!requireDesktop('Screen recording')) break; if (!requireDesktop('Screen recording')) break;
setRecordingSource('screen'); setRecordingSource('screen');
setStepSync('picking'); setStepSync('picking');
} else if (e.key === 't' || e.key === 'T') { } else if (e.key === 't' || e.key === 'T') {
e.preventDefault(); consume();
handleTextIntent(); handleTextIntent();
} else if (e.key === 'd' || e.key === 'D') { } else if (e.key === 'd' || e.key === 'D') {
e.preventDefault(); consume();
handleTaskIntent(); handleTaskIntent();
} }
break; break;
@@ -641,17 +653,17 @@ export function ComposeOverlay({
case 'recording': { case 'recording': {
if (e.key === '`' && !e.repeat) { if (e.key === '`' && !e.repeat) {
// Second tap stops media recording (toggle mode) // Second tap stops media recording (toggle mode)
e.preventDefault(); consume();
handleStopIntent(); handleStopIntent();
} else if ( } else if (
(e.key === 's' || e.key === 'S') && (e.key === 's' || e.key === 'S') &&
recordingSourceRef.current === 'screen' recordingSourceRef.current === 'screen'
) { ) {
// S stops screen recording when main window is focused // S stops screen recording when main window is focused
e.preventDefault(); consume();
handleStopIntent(); handleStopIntent();
} else if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') { } else if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
e.preventDefault(); consume();
handleCancelIntent(); handleCancelIntent();
} }
break; break;
@@ -659,10 +671,10 @@ export function ComposeOverlay({
case 'reviewing': { case 'reviewing': {
if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') { if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
e.preventDefault(); consume();
handleCancelIntent(); handleCancelIntent();
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault(); consume();
handleSendIntent(); handleSendIntent();
} }
break; break;
@@ -689,10 +701,13 @@ export function ComposeOverlay({
} }
}; };
window.addEventListener('keydown', handleKeyDown); // Capture phase so compose can consume keys before the stream's
// window-level navigation/action handlers (which listen in the bubble
// phase) see them.
window.addEventListener('keydown', handleKeyDown, true);
window.addEventListener('keyup', handleKeyUp); window.addEventListener('keyup', handleKeyUp);
return () => { return () => {
window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keydown', handleKeyDown, true);
window.removeEventListener('keyup', handleKeyUp); window.removeEventListener('keyup', handleKeyUp);
}; };
}, [ }, [
@@ -755,7 +770,7 @@ export function ComposeOverlay({
/> />
)} )}
{step === 'recording' && recordingSource === 'screen' && ( {step === 'recording' && recordingSource === 'screen' && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90"> <div className="bg-scrim/90 absolute inset-0 z-50 flex flex-col items-center justify-center">
<div className="absolute top-8 z-10"> <div className="absolute top-8 z-10">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" /> <span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
@@ -827,8 +842,8 @@ export function ComposeOverlay({
/> />
)} )}
{step === 'submitting' && ( {step === 'submitting' && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90"> <div className="bg-background/95 absolute inset-0 z-50 flex flex-col items-center justify-center backdrop-blur-sm">
<span className="animate-pulse text-sm text-white/60"> <span className="text-muted-foreground animate-pulse text-sm">
Sending... Sending...
</span> </span>
</div> </div>
@@ -77,13 +77,13 @@ export function ConfigureContainerStep({
return ( return (
<div <div
className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16" className="bg-background/95 absolute inset-0 z-50 flex flex-col pt-16 backdrop-blur-sm"
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
tabIndex={-1} tabIndex={-1}
> >
<div className="mx-auto w-full max-w-sm space-y-4 px-6"> <div className="mx-auto w-full max-w-sm space-y-4 px-6">
<div> <div>
<Label className="mb-1 text-xs text-white/50"> <Label className="text-muted-foreground mb-1 text-xs">
{kind === 'folder' ? 'Folder name' : 'Stream name'} {kind === 'folder' ? 'Folder name' : 'Stream name'}
</Label> </Label>
<Input <Input
@@ -94,21 +94,22 @@ export function ConfigureContainerStep({
setName(e.target.value); setName(e.target.value);
}} }}
placeholder="Give it a name..." placeholder="Give it a name..."
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
/> />
</div> </div>
{/* Visibility */} {/* Visibility */}
<div> <div>
<Label className="mb-1 text-xs text-white/50">Visible to</Label> <Label className="text-muted-foreground mb-1 text-xs">
<div className="rounded-md border border-white/10"> Visible to
</Label>
<div className="border-border rounded-md border">
{/* Everyone in network */} {/* Everyone in network */}
<div <div
role="button" role="button"
onClick={() => setEveryone((prev) => !prev)} onClick={() => setEveryone((prev) => !prev)}
className={cn( className={cn(
'flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors', 'flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors',
'text-white/70 hover:bg-white/5', 'hover:bg-accent',
)} )}
> >
<Checkbox <Checkbox
@@ -137,7 +138,7 @@ export function ConfigureContainerStep({
onClick={() => toggleMember(member.id)} onClick={() => toggleMember(member.id)}
className={cn( className={cn(
'flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors', 'flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors',
'text-white/70 hover:bg-white/5', 'hover:bg-accent',
)} )}
> >
<Checkbox <Checkbox
@@ -145,7 +146,7 @@ export function ConfigureContainerStep({
tabIndex={-1} tabIndex={-1}
className="pointer-events-none" className="pointer-events-none"
/> />
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/10 text-[10px] font-medium"> <span className="bg-muted flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-medium">
{initials} {initials}
</span> </span>
<span className="flex-1 truncate"> <span className="flex-1 truncate">
@@ -162,7 +163,7 @@ export function ConfigureContainerStep({
</div> </div>
{/* Keyboard hints */} {/* Keyboard hints */}
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50"> <div className="text-muted-foreground absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm">
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)"> <KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
cancel cancel
</KeyHint> </KeyHint>
@@ -1,13 +1,14 @@
/* Theme Milkdown Crepe to blend into the app's translucent "glass" surfaces. /* Theme Milkdown Crepe to the app's design tokens so it follows light/dark.
* Crepe's frame-dark theme is a grayscale palette driven by --crepe-* custom * Crepe's theme is a palette driven by --crepe-* custom properties; we map
* properties; we override those to white-on-transparent with a blue accent so * those onto our CSS variables (globals.css) so the editor (and the read-only
* the editor (and the read-only display, which uses the same engine) sits on * display, which uses the same engine) inherits the active theme and sits on
* top of the existing card instead of painting its own opaque background. * top of the existing card instead of painting its own opaque background.
* *
* The floating menus (slash menu, selection toolbar, link tooltip) are portaled * The floating menus (slash menu, selection toolbar, link tooltip) are portaled
* to <body>, OUTSIDE .milkdown — so the variables must be declared on those * to <body>, OUTSIDE .milkdown — so the variables must be declared on those
* selectors too, otherwise they fall back to transparent and the menu is * selectors too, otherwise they fall back to transparent and the menu is
* unreadable over the content behind it. */ * unreadable over the content behind it. Our tokens resolve against the
* :root/.dark cascade on <html>, so the portaled menus stay themed too. */
.llink-crepe .milkdown, .llink-crepe .milkdown,
.milkdown-slash-menu, .milkdown-slash-menu,
.milkdown-toolbar, .milkdown-toolbar,
@@ -15,24 +16,30 @@
.milkdown-link-preview, .milkdown-link-preview,
.milkdown-block-handle { .milkdown-block-handle {
--crepe-color-background: transparent; --crepe-color-background: transparent;
--crepe-color-on-background: rgb(255 255 255 / 0.92); --crepe-color-on-background: var(--card-foreground);
/* Surface backs code blocks, tables, and the floating menus — keep it /* Surface backs code blocks, tables, and the floating menus — use an opaque
* (near-)opaque so menus read clearly over content. */ * themed surface so menus read clearly over content. */
--crepe-color-surface: rgb(24 24 28 / 0.96); --crepe-color-surface: var(--popover);
--crepe-color-surface-low: rgb(42 42 50 / 0.96); --crepe-color-surface-low: var(--muted);
--crepe-color-on-surface: #ffffff; --crepe-color-on-surface: var(--popover-foreground);
--crepe-color-on-surface-variant: rgb(255 255 255 / 0.65); --crepe-color-on-surface-variant: var(--muted-foreground);
--crepe-color-outline: rgb(255 255 255 / 0.2); --crepe-color-outline: var(--border);
--crepe-color-primary: #60a5fa; --crepe-color-primary: var(--primary);
--crepe-color-secondary: rgb(96 165 250 / 0.25); --crepe-color-secondary: color-mix(in oklch, var(--primary) 22%, transparent);
--crepe-color-on-secondary: #ffffff; --crepe-color-on-secondary: var(--foreground);
--crepe-color-inverse: #ffffff; --crepe-color-inverse: var(--foreground);
--crepe-color-on-inverse: #0b0b0b; --crepe-color-on-inverse: var(--background);
--crepe-color-inline-code: #fca5a5; --crepe-color-inline-code: var(--primary);
--crepe-color-inline-area: rgb(255 255 255 / 0.1); --crepe-color-inline-area: var(--muted);
--crepe-color-error: #f87171; --crepe-color-error: var(--destructive);
--crepe-color-hover: rgb(255 255 255 / 0.08); --crepe-color-hover: var(--accent);
--crepe-color-selected: rgb(96 165 250 / 0.25); --crepe-color-selected: color-mix(in oklch, var(--primary) 22%, transparent);
/* frame-dark ships white "glow" shadows that vanish on light surfaces;
* use a conventional dark drop shadow so floating menus lift in both themes. */
--crepe-shadow-1:
0px 1px 2px 0px rgb(0 0 0 / 0.12), 0px 1px 3px 1px rgb(0 0 0 / 0.08);
--crepe-shadow-2:
0px 1px 2px 0px rgb(0 0 0 / 0.16), 0px 2px 6px 2px rgb(0 0 0 / 0.12);
/* Use the application font, not Crepe's bundled Noto Sans / Noto Serif. */ /* Use the application font, not Crepe's bundled Noto Sans / Noto Serif. */
--crepe-font-default: inherit; --crepe-font-default: inherit;
--crepe-font-title: inherit; --crepe-font-title: inherit;
@@ -57,7 +64,7 @@
.llink-crepe .milkdown .ProseMirror { .llink-crepe .milkdown .ProseMirror {
padding: 0; padding: 0;
outline: none; outline: none;
caret-color: white; caret-color: var(--foreground);
} }
/* Crepe's default heading scale (42px h1) is sized for a document editor; /* Crepe's default heading scale (42px h1) is sized for a document editor;
@@ -103,33 +110,19 @@
line-height: 1.375rem; line-height: 1.375rem;
font-weight: 700; font-weight: 700;
margin-top: 12px; margin-top: 12px;
color: rgb(255 255 255 / 0.7); color: var(--muted-foreground);
} }
.llink-crepe .milkdown .ProseMirror > :first-child { .llink-crepe .milkdown .ProseMirror > :first-child {
margin-top: 0; margin-top: 0;
} }
/* Images come from URLs only (no stable public upload URL), so hide the /* Image *links* render through the base commonmark schema (the ImageBlock
* file uploader — the placeholder then just prompts for a link. */ * feature is off — see markdown-editor.tsx). Keep them within the content
.llink-crepe * column and softly rounded. */
.milkdown .llink-crepe .milkdown .ProseMirror img {
:is(.milkdown-image-block, .milkdown-image-inline) max-width: 100%;
.placeholder max-height: 420px;
.uploader {
display: none;
}
/* Read-only renders the image node view with inert editing chrome — hide it. */
.llink-crepe:not(.llink-crepe--fill) .milkdown .milkdown-image-block .operation,
.llink-crepe:not(.llink-crepe--fill)
.milkdown
.milkdown-image-block
.image-resize-handle {
display: none;
}
.llink-crepe .milkdown .milkdown-image-block img {
border-radius: 8px; border-radius: 8px;
} }
@@ -1,11 +1,12 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { Crepe } from '@milkdown/crepe'; import { Crepe } from '@milkdown/crepe';
import { editorViewCtx } from '@milkdown/kit/core'; import { editorViewCtx, editorViewOptionsCtx } from '@milkdown/kit/core';
import { Selection } from '@milkdown/kit/prose/state'; import { Selection } from '@milkdown/kit/prose/state';
import '@milkdown/crepe/theme/common/style.css'; import '@milkdown/crepe/theme/common/style.css';
import '@milkdown/crepe/theme/frame-dark.css'; import '@milkdown/crepe/theme/frame-dark.css';
import './markdown-editor.css'; import './markdown-editor.css';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { transferFiles } from '@/lib/data-transfer';
interface MarkdownEditorProps { interface MarkdownEditorProps {
/** Initial markdown. The editor owns its content after mount; edits flow out /** Initial markdown. The editor owns its content after mount; edits flow out
@@ -57,23 +58,18 @@ export function MarkdownEditor({
[Crepe.Feature.BlockEdit]: !readOnly, [Crepe.Feature.BlockEdit]: !readOnly,
[Crepe.Feature.Toolbar]: !readOnly, [Crepe.Feature.Toolbar]: !readOnly,
[Crepe.Feature.Placeholder]: !readOnly, [Crepe.Feature.Placeholder]: !readOnly,
[Crepe.Feature.ImageBlock]: true, // Off on purpose: file uploads aren't supported, so this feature's
// paste/drop handler would only strand an "upload in progress" node in
// the editor. Image *links* still render through the base commonmark
// schema, and pasted image files are routed to the compose attachment
// strip (see use-file-input).
[Crepe.Feature.ImageBlock]: false,
[Crepe.Feature.Latex]: false, [Crepe.Feature.Latex]: false,
[Crepe.Feature.TopBar]: false, [Crepe.Feature.TopBar]: false,
[Crepe.Feature.AI]: false, [Crepe.Feature.AI]: false,
}, },
featureConfigs: { featureConfigs: {
[Crepe.Feature.Placeholder]: { text: placeholder ?? '' }, [Crepe.Feature.Placeholder]: { text: placeholder ?? '' },
// Images come from URLs only (e.g. pasted markdown) — there is no
// stable public upload URL, so the file uploader is hidden in CSS and
// onUpload rejects in case a file ever reaches it anyway (the default
// would serialize an ephemeral blob: URL into the message).
[Crepe.Feature.ImageBlock]: {
blockUploadPlaceholderText: 'Paste an image link…',
inlineUploadPlaceholderText: 'paste an image link',
maxHeight: 420,
onUpload: () => Promise.reject(new Error('Image uploads disabled')),
},
}, },
}); });
@@ -85,6 +81,21 @@ export function MarkdownEditor({
onChangeRef.current?.(markdown); onChangeRef.current?.(markdown);
}); });
}); });
// Decline dropped files: they belong in the compose attachment strip
// (the drop zone still receives them), not inlined into the document.
// Without this the editor parses the drag's HTML into an image node with
// an ephemeral blob:/localhost src, which then leaks into the markdown.
crepe.editor.config((ctx) => {
ctx.update(editorViewOptionsCtx, (prev) => ({
...prev,
handleDrop: (view, event, slice, moved) => {
const data = event.dataTransfer;
if (data && transferFiles(data).length > 0) return true;
return prev.handleDrop?.(view, event, slice, moved) ?? false;
},
}));
});
} }
crepe.create().then(() => { crepe.create().then(() => {
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { Paperclip } from 'lucide-react'; import { Paperclip } from 'lucide-react';
import type { RecordingMode } from '@/stores/media-settings-store'; import type { RecordingMode } from '@/stores/media-settings-store';
import { CenteredWaveform } from '@/components/audio/centered-waveform'; import { CenteredWaveform } from '@/components/audio/centered-waveform';
@@ -7,6 +8,7 @@ import { useObjectUrl } from '@/hooks/use-object-url';
import { AttachmentStrip } from '@/features/compose/attachment-strip'; import { AttachmentStrip } from '@/features/compose/attachment-strip';
import type { PendingAttachment } from '@/features/compose/attachment-strip'; import type { PendingAttachment } from '@/features/compose/attachment-strip';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { RECORDING_MAX_DURATION_SECONDS } from '@/lib/constants';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint'; import { KeyHint } from '@/components/key-hint';
import { useComposeIntentStore } from '@/stores/compose-intent-store'; import { useComposeIntentStore } from '@/stores/compose-intent-store';
@@ -34,16 +36,41 @@ interface RecordingOverlayProps {
objectFit?: 'cover' | 'contain'; objectFit?: 'cover' | 'contain';
} }
function RecordingTimer() { function useRecordingCountdown(active: boolean) {
const [elapsed, setElapsed] = useState(0); const [elapsed, setElapsed] = useState(0);
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { if (!active) return;
setElapsed((prev) => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
let toastSent = false;
const start = Date.now();
const interval = setInterval(() => {
const seconds = Math.floor((Date.now() - start) / 1000);
setElapsed(seconds);
if (seconds >= RECORDING_MAX_DURATION_SECONDS && !toastSent) {
toast.warning(
'That is a long recording, cancel and re-record with your distilled thoughts',
);
toastSent = true;
}
}, 1000);
return () => {
clearInterval(interval);
setElapsed(0);
};
}, [active]);
return { elapsed, isWarning: elapsed >= RECORDING_MAX_DURATION_SECONDS };
}
function RecordingTimer({
elapsed,
isWarning,
}: {
elapsed: number;
isWarning: boolean;
}) {
const minutes = Math.floor(elapsed / 60); const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60; const seconds = elapsed % 60;
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`; const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
@@ -51,7 +78,14 @@ function RecordingTimer() {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" /> <span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
<span className="font-mono text-sm text-white/80">{display}</span> <span
className={cn(
'font-mono text-sm text-white/80',
isWarning && 'text-red-400',
)}
>
{display}
</span>
</div> </div>
); );
} }
@@ -137,14 +171,31 @@ export function RecordingOverlay({
const isLoading = isRecording && !mediaStream; const isLoading = isRecording && !mediaStream;
const requestIntent = useComposeIntentStore((s) => s.request); const requestIntent = useComposeIntentStore((s) => s.request);
const { elapsed, isWarning } = useRecordingCountdown(
isRecording && !isLoading,
);
return ( return (
<div <div
className={cn( className={cn(
'absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90', 'bg-scrim/90 absolute inset-0 z-50 flex flex-col items-center justify-center',
isReviewing && isDragging && 'ring-2 ring-inset ring-white/30', isReviewing && isDragging && 'ring-2 ring-inset ring-white/30',
isRecording && isWarning && 'record-warning-glow',
)} )}
{...(isReviewing ? dropZoneProps : {})} {...(isReviewing ? dropZoneProps : {})}
> >
{/* Top progress bar: fills over the recording duration, red in warning */}
{isRecording && !isLoading && (
<div className="absolute inset-x-0 top-0 z-20 h-1 bg-white/10">
<div
className={cn(
'h-full w-full origin-left record-progress',
isWarning ? 'bg-red-500' : 'bg-white/80',
)}
style={{ animationDuration: `${RECORDING_MAX_DURATION_SECONDS}s` }}
/>
</div>
)}
{/* Loading state */} {/* Loading state */}
{isLoading && ( {isLoading && (
<div className="z-10 flex flex-col items-center gap-2"> <div className="z-10 flex flex-col items-center gap-2">
@@ -179,13 +230,13 @@ export function RecordingOverlay({
{/* Bottom gradient scrim for keyboard hint readability */} {/* Bottom gradient scrim for keyboard hint readability */}
{(isRecording || isReviewing) && !isLoading && ( {(isRecording || isReviewing) && !isLoading && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t from-black/60 to-transparent" /> <div className="from-scrim/60 pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t to-transparent" />
)} )}
{/* Top center: recording indicator */} {/* Top center: recording indicator */}
<div className="absolute top-8 z-10"> <div className="absolute top-8 z-10">
{isRecording && !isLoading ? ( {isRecording && !isLoading ? (
<RecordingTimer /> <RecordingTimer elapsed={elapsed} isWarning={isWarning} />
) : isReviewing ? ( ) : isReviewing ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-white/80">Review recording</span> <span className="text-sm text-white/80">Review recording</span>
@@ -63,37 +63,38 @@ export function TaskComposeStep({
return ( return (
<div <div
className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16" className="bg-background/95 absolute inset-0 z-50 flex flex-col pt-16 backdrop-blur-sm"
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
tabIndex={-1} tabIndex={-1}
> >
<div className="mx-auto w-full max-w-sm space-y-4 px-6"> <div className="mx-auto w-full max-w-sm space-y-4 px-6">
<div> <div>
<Label className="mb-1 text-xs text-white/50">Task</Label> <Label className="text-muted-foreground mb-1 text-xs">Task</Label>
<Input <Input
type="text" type="text"
autoFocus autoFocus
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="What needs to get done?" placeholder="What needs to get done?"
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
/> />
</div> </div>
<div> <div>
<Label className="mb-1 text-xs text-white/50">Notes</Label> <Label className="text-muted-foreground mb-1 text-xs">Notes</Label>
<Textarea <Textarea
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
placeholder="Optional details…" placeholder="Optional details…"
className="min-h-20 border-white/10 bg-white/5 text-white placeholder:text-white/30 focus-visible:border-white/30 focus-visible:ring-0 dark:bg-white/5" className="min-h-20"
/> />
</div> </div>
<div> <div>
<Label className="mb-1 text-xs text-white/50">Assign to</Label> <Label className="text-muted-foreground mb-1 text-xs">
Assign to
</Label>
<Select value={assignedTo} onValueChange={setAssignedTo}> <Select value={assignedTo} onValueChange={setAssignedTo}>
<SelectTrigger className="w-full border-white/10 bg-white/5 text-white"> <SelectTrigger className="w-full">
<SelectValue placeholder="Unassigned" /> <SelectValue placeholder="Unassigned" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -108,7 +109,7 @@ export function TaskComposeStep({
</div> </div>
</div> </div>
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50"> <div className="text-muted-foreground absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm">
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)"> <KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
cancel cancel
</KeyHint> </KeyHint>
@@ -112,7 +112,7 @@ export function TextEditor({
); );
const keyboardHints = ( const keyboardHints = (
<div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50"> <div className="text-muted-foreground absolute bottom-4 flex items-center gap-4 text-sm">
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)"> <KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
cancel cancel
</KeyHint> </KeyHint>
@@ -161,8 +161,8 @@ export function TextEditor({
return ( return (
<div <div
className={cn( className={cn(
'absolute inset-0 z-50 flex items-center justify-center bg-black/90', 'bg-background/95 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm',
isDragging && 'ring-2 ring-inset ring-white/30', isDragging && 'ring-primary/40 ring-2 ring-inset',
)} )}
{...dropZoneProps} {...dropZoneProps}
> >
@@ -174,7 +174,7 @@ export function TextEditor({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Type a message..." placeholder="Type a message..."
className={cn( className={cn(
'w-full resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none', 'text-foreground placeholder-muted-foreground w-full resize-none border-none bg-transparent p-8 text-center outline-none',
style.size, style.size,
style.weight, style.weight,
)} )}
@@ -189,13 +189,13 @@ export function TextEditor({
return ( return (
<div <div
className={cn( className={cn(
'absolute inset-0 z-50 flex items-center justify-center bg-black/90', 'bg-background/95 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm',
isDragging && 'ring-2 ring-inset ring-white/30', isDragging && 'ring-primary/40 ring-2 ring-inset',
)} )}
{...dropZoneProps} {...dropZoneProps}
onKeyDownCapture={handleKeyDown} onKeyDownCapture={handleKeyDown}
> >
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 max-w-[calc(var(--message-content-width)_+_var(--message-editor-gutter)*2)] flex-col overflow-hidden rounded border border-white/10 bg-white/5 backdrop-blur-xl animate-in fade-in zoom-in-95 duration-200"> <div className="bg-card text-card-foreground border-border animate-in fade-in zoom-in-95 mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 max-w-[calc(var(--message-content-width)_+_var(--message-editor-gutter)*2)] flex-col overflow-hidden rounded border duration-200">
{/* No padding here: the editor's own scroll box hosts the slash menu, {/* No padding here: the editor's own scroll box hosts the slash menu,
so we pad inside the editor (ProseMirror) instead. That keeps the so we pad inside the editor (ProseMirror) instead. That keeps the
menu's clipping bounds the full card rather than the inset box. */} menu's clipping bounds the full card rather than the inset box. */}
@@ -208,7 +208,7 @@ export function TextEditor({
</div> </div>
{hasEnrichments && strip && ( {hasEnrichments && strip && (
<div className="shrink-0 border-t border-white/10 px-5 py-3"> <div className="border-border shrink-0 border-t px-5 py-3">
{strip} {strip}
</div> </div>
)} )}
@@ -0,0 +1,52 @@
import { useEffect } from 'react';
import { Check } from 'lucide-react';
import { OnboardingKeyboard } from './onboarding-keyboard';
import { OnboardingStep, SUCCESS_DWELL_MS } from './onboarding-step';
import { useHoldGesture } from './use-hold-gesture';
/**
* Visible hold duration for the lesson. Deliberately longer than the real
* HOLD_THRESHOLD_MS (which is imperceptible) so the gesture is teachable.
*/
const ONBOARDING_HOLD_MS = 600;
export function HoldStep({ onAdvance }: { onAdvance: () => void }) {
const { state, progress } = useHoldGesture({
targetKey: '`',
targetCode: 'Backquote',
holdMs: ONBOARDING_HOLD_MS,
});
const done = state === 'success';
useEffect(() => {
if (!done) return;
const t = setTimeout(onAdvance, SUCCESS_DWELL_MS);
return () => clearTimeout(t);
}, [done, onAdvance]);
return (
<OnboardingStep
title={done ? 'That is push-to-talk' : 'Hold to record'}
subtitle={
done
? 'Hold ` whenever you want to speak, release when you are done.'
: 'Press and hold the ` key, like a walkie-talkie.'
}
status={
done ? (
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-300">
<Check className="size-4" /> Nice.
</span>
) : state === 'holding' ? (
'Keep holding…'
) : null
}
>
<OnboardingKeyboard
highlightKey="`"
state={done ? 'success' : state === 'holding' ? 'active' : 'idle'}
progress={progress}
/>
</OnboardingStep>
);
}
@@ -0,0 +1,79 @@
import { cn } from '@/lib/utils';
type KeyState = 'idle' | 'active' | 'success';
interface OnboardingKeyboardProps {
/** The key to emphasise. Defaults to the record key. */
highlightKey?: string;
state: KeyState;
/** Hold progress (01), fills the emphasised key from the bottom. */
progress?: number;
}
interface KeyCapProps {
label: string;
hero?: boolean;
state?: KeyState;
progress?: number;
className?: string;
}
function KeyCap({
label,
hero,
state = 'idle',
progress = 0,
className,
}: KeyCapProps) {
return (
<div
className={cn(
'relative flex select-none items-center justify-center overflow-hidden rounded-lg border text-sm font-medium',
hero ? 'h-14 w-14 text-xl' : 'h-14',
!hero && 'border-border bg-muted text-muted-foreground/70',
hero &&
state === 'idle' &&
'border-primary/40 bg-primary/10 text-foreground motion-safe:animate-pulse',
hero &&
state === 'active' &&
'border-primary/60 bg-primary/20 text-foreground',
hero &&
state === 'success' &&
'border-emerald-500/50 bg-emerald-500/15 text-emerald-700 dark:text-emerald-200',
className,
)}
>
{hero && state !== 'success' && progress > 0 && (
<span
className="pointer-events-none absolute inset-x-0 bottom-0 bg-primary/25"
style={{ height: `${progress * 100}%` }}
/>
)}
<span className="relative z-10">{label}</span>
</div>
);
}
/**
* Draws the record key in its physical context: Esc above, 1 to its right, and
* Tab / Q on the row below — so the key is recognisable on a real keyboard.
*/
export function OnboardingKeyboard({
highlightKey = '`',
state,
progress = 0,
}: OnboardingKeyboardProps) {
return (
<div className="flex flex-col items-start gap-1.5">
<KeyCap label="esc" className="w-14 text-xs" />
<div className="flex gap-1.5">
<KeyCap label={highlightKey} hero state={state} progress={progress} />
<KeyCap label="1" className="w-14" />
</div>
<div className="flex gap-1.5">
<KeyCap label="tab" className="w-20 text-xs" />
<KeyCap label="Q" className="w-12" />
</div>
</div>
);
}
@@ -0,0 +1,140 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { WindowControls } from '@/components/window-controls';
import { KeyHint } from '@/components/key-hint';
import { cn } from '@/lib/utils';
import { OnboardingKeyboard } from './onboarding-keyboard';
import { OnboardingStep } from './onboarding-step';
import { HoldStep } from './hold-step';
import { ToggleStep } from './toggle-step';
import { TapStep } from './tap-step';
import { PermissionStep } from './permission-step';
const STEPS = ['welcome', 'hold', 'tap', 'toggle', 'permission'] as const;
type Step = (typeof STEPS)[number];
/**
* First-run wizard that teaches the record key. Rendered as a full-screen
* takeover (above PusherProvider) so none of the stream/compose keyboard
* handlers are mounted to compete for the same keys.
*/
export function OnboardingOverlay({ onComplete }: { onComplete: () => void }) {
const [step, setStep] = useState<Step>('welcome');
const panelRef = useRef<HTMLDivElement>(null);
const index = STEPS.indexOf(step);
const goNext = useCallback(() => {
setStep((cur) => STEPS[Math.min(STEPS.indexOf(cur) + 1, STEPS.length - 1)]);
}, []);
const goBack = useCallback(() => {
setStep((cur) => STEPS[Math.max(0, STEPS.indexOf(cur) - 1)]);
}, []);
// Focus the panel on the gesture steps so window-level key handling has a
// home; the static steps autofocus their primary button instead, which lets
// Enter activate it natively (no duplicate window handler).
useEffect(() => {
if (step === 'hold' || step === 'toggle' || step === 'tap') {
panelRef.current?.focus();
}
}, [step]);
// Esc skips the wizard from anywhere; ← (and Backspace) steps back.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onComplete();
} else if (e.key === 'Enter' && step === 'welcome') {
e.preventDefault();
goNext();
} else if (
e.key === 'ArrowLeft' ||
e.key === 'Backspace' ||
e.key === 'Delete'
) {
e.preventDefault();
goBack();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onComplete, goNext, step, goBack]);
return (
<div className="flex h-screen flex-col bg-background text-foreground">
<div className="drag-region flex items-center justify-between border-b border-border px-3 py-1">
<WindowControls />
<KeyHint
keys="Esc"
onClick={onComplete}
title="Skip onboarding (or press Esc)"
className="no-drag text-xs text-muted-foreground"
>
to skip
</KeyHint>
</div>
<div className="flex flex-1 items-center justify-center p-6">
<div
ref={panelRef}
tabIndex={-1}
role="dialog"
aria-label="Welcome to llink"
className="flex w-full max-w-md flex-col items-center gap-8 rounded-2xl border border-border bg-card p-10 outline-none"
>
{step === 'welcome' && (
<OnboardingStep
title="Welcome to llink"
subtitle="To use llink, you must learn the keyboard shortcuts. Don't worry, once you learn them, you'll feel like your flowing."
>
<OnboardingKeyboard highlightKey="`" state="idle" />
</OnboardingStep>
)}
{step === 'hold' && <HoldStep onAdvance={goNext} />}
{step === 'toggle' && <ToggleStep onAdvance={goNext} />}
{step === 'tap' && <TapStep onAdvance={goNext} />}
{step === 'permission' && <PermissionStep onAdvance={onComplete} />}
<div className="flex items-center justify-center gap-1.5">
{STEPS.map((s, i) => (
<span
key={s}
className={cn(
'h-1.5 rounded-full transition-all',
i === index
? 'w-6 bg-foreground'
: i < index
? 'w-1.5 bg-foreground/40'
: 'w-1.5 bg-foreground/15',
)}
/>
))}
</div>
<div className="flex min-h-5 items-center justify-center gap-4 text-xs text-muted-foreground">
{index > 0 && (
<KeyHint
keys="←"
onClick={goBack}
title="Back (or press ←)"
aria-label="Previous step"
>
back
</KeyHint>
)}
{step === 'welcome' && (
<KeyHint
keys="Enter"
onClick={goNext}
title="Continue (or press Enter)"
>
continue
</KeyHint>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
import type { ReactNode } from 'react';
/** How long a success state lingers before the wizard auto-advances, in ms. */
export const SUCCESS_DWELL_MS = 2000;
interface OnboardingStepProps {
title: ReactNode;
subtitle?: ReactNode;
/** The interactive body (keyboard diagram, toggle, etc.). */
children?: ReactNode;
/** Live feedback line, announced to screen readers. */
status?: ReactNode;
/** Actions and hints below the body. */
footer?: ReactNode;
}
/** Shared layout so every onboarding step keeps the same vertical rhythm. */
export function OnboardingStep({
title,
subtitle,
children,
status,
footer,
}: OnboardingStepProps) {
return (
<div className="flex flex-col items-center gap-6 text-center">
<div className="space-y-2">
<h2 className="text-2xl font-semibold tracking-tight text-foreground">
{title}
</h2>
{subtitle && (
<p className="mx-auto max-w-xs text-sm text-muted-foreground">
{subtitle}
</p>
)}
</div>
{children && (
<div className="flex flex-col items-center gap-4 py-2">{children}</div>
)}
<p aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{status}
</p>
{footer && (
<div className="flex flex-col items-center gap-3">{footer}</div>
)}
</div>
);
}
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import { Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { OnboardingStep, SUCCESS_DWELL_MS } from './onboarding-step';
import { useMediaDevices } from '@/hooks/use-media-devices';
export function PermissionStep({ onAdvance }: { onAdvance: () => void }) {
const { permissionState, requestLabels } = useMediaDevices();
const [requesting, setRequesting] = useState(false);
const granted = permissionState === 'granted';
const denied = permissionState === 'denied';
useEffect(() => {
if (!granted) return;
const t = setTimeout(onAdvance, SUCCESS_DWELL_MS);
return () => clearTimeout(t);
}, [granted, onAdvance]);
const handleAllow = async () => {
setRequesting(true);
await requestLabels();
setRequesting(false);
};
return (
<OnboardingStep
title="Allow mic and camera"
subtitle="llink records audio and video. Grant access once so recording just works."
status={
granted ? (
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-300">
<Check className="size-4" /> Access granted.
</span>
) : denied ? (
<span className="text-muted-foreground">
No access yet. You can enable it later in System Settings.
</span>
) : null
}
footer={
granted ? null : denied ? (
<Button variant="secondary" onClick={onAdvance} autoFocus>
Continue anyway
</Button>
) : (
<Button onClick={handleAllow} disabled={requesting} autoFocus>
{requesting ? 'Requesting…' : 'Allow access'}
</Button>
)
}
/>
);
}
@@ -0,0 +1,56 @@
import { useEffect } from 'react';
import { Check } from 'lucide-react';
import { OnboardingKeyboard } from './onboarding-keyboard';
import { OnboardingStep, SUCCESS_DWELL_MS } from './onboarding-step';
import { useTapGesture } from './use-tap-gesture';
export function TapStep({ onAdvance }: { onAdvance: () => void }) {
const { taps, satisfied } = useTapGesture({
targetKey: '`',
targetCode: 'Backquote',
taps: 2,
});
const recording = taps === 1;
useEffect(() => {
if (!satisfied) return;
const t = setTimeout(onAdvance, SUCCESS_DWELL_MS);
return () => clearTimeout(t);
}, [satisfied, onAdvance]);
return (
<OnboardingStep
title={
satisfied
? 'That is tap to toggle'
: recording
? 'Recording…'
: 'Tap to start and stop'
}
subtitle={
satisfied
? 'A quick tap starts recording, another tap finishes it.'
: recording
? 'Now tap ` again to stop.'
: 'Prefer not to hold? Tap ` once to start recording.'
}
status={
satisfied ? (
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-300">
<Check className="size-4" /> Done.
</span>
) : recording ? (
<span className="inline-flex items-center gap-2 text-muted-foreground">
<span className="size-2.5 rounded-full bg-red-500 motion-safe:animate-pulse" />
Recording
</span>
) : null
}
>
<OnboardingKeyboard
highlightKey="`"
state={satisfied ? 'success' : recording ? 'active' : 'idle'}
/>
</OnboardingStep>
);
}
@@ -0,0 +1,60 @@
import { useEffect } from 'react';
import { Check, Mic, Video } from 'lucide-react';
import { OnboardingStep, SUCCESS_DWELL_MS } from './onboarding-step';
import { useTapGesture } from './use-tap-gesture';
import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { useMediaSettingsStore } from '@/stores/media-settings-store';
export function ToggleStep({ onAdvance }: { onAdvance: () => void }) {
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
const { satisfied } = useTapGesture({
targetKey: 'v',
onTap: () => {
const current = useMediaSettingsStore.getState().recordingMode;
setRecordingMode(current === 'video' ? 'audio' : 'video');
},
});
useEffect(() => {
if (!satisfied) return;
const t = setTimeout(onAdvance, SUCCESS_DWELL_MS);
return () => clearTimeout(t);
}, [satisfied, onAdvance]);
// Teaching the toggle shouldn't permanently change the user's default mode.
useEffect(() => {
const original = useMediaSettingsStore.getState().recordingMode;
return () => setRecordingMode(original);
}, [setRecordingMode]);
const isVideo = recordingMode === 'video';
return (
<OnboardingStep
title={satisfied ? 'Audio or video, your call' : 'Switch audio and video'}
subtitle={
satisfied
? 'Tap V before recording to choose how you show up.'
: 'Tap the V key to switch between video and audio.'
}
status={
satisfied ? (
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-300">
<Check className="size-4" />{' '}
{isVideo ? 'Back to video.' : 'Audio it is.'}
</span>
) : null
}
>
<div className="flex size-32 flex-col items-center justify-center gap-2 rounded-2xl border border-border bg-muted text-foreground">
{isVideo ? <Video className="size-9" /> : <Mic className="size-9" />}
<span className="text-sm text-muted-foreground">
{isVideo ? 'Video' : 'Audio'}
</span>
</div>
<VideoAudioToggle />
</OnboardingStep>
);
}
@@ -0,0 +1,75 @@
import { useEffect, useState } from 'react';
import { isTypingTarget } from '@/lib/keyboard';
export type HoldState = 'idle' | 'holding' | 'success';
interface UseHoldGestureOptions {
/** Detected against KeyboardEvent.key. */
targetKey: string;
/** Optional KeyboardEvent.code fallback (e.g. 'Backquote'). */
targetCode?: string;
/** How long the key must be held before it counts, in ms. */
holdMs: number;
}
/**
* Detects a deliberate press-and-hold of a single key. Success is driven by a
* timer started on keydown rather than by keyup, so it still resolves if the OS
* swallows the keyup (e.g. macOS press-and-hold accent popover). Releasing early
* simply resets — a tutorial never fails the user.
*/
export function useHoldGesture({
targetKey,
targetCode,
holdMs,
}: UseHoldGestureOptions) {
const [state, setState] = useState<HoldState>('idle');
const [progress, setProgress] = useState(0);
useEffect(() => {
let rafId = 0;
let startedAt = 0;
let done = false;
const matches = (e: KeyboardEvent) =>
e.key === targetKey || (targetCode != null && e.code === targetCode);
const tick = () => {
const next = Math.min((Date.now() - startedAt) / holdMs, 1);
setProgress(next);
if (next >= 1) {
done = true;
setState('success');
return;
}
rafId = requestAnimationFrame(tick);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (done || e.repeat || isTypingTarget(e) || !matches(e)) return;
e.preventDefault();
startedAt = Date.now();
setState('holding');
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(tick);
};
const handleKeyUp = (e: KeyboardEvent) => {
if (done || !matches(e)) return;
cancelAnimationFrame(rafId);
startedAt = 0;
setProgress(0);
setState('idle');
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [targetKey, targetCode, holdMs]);
return { state, progress };
}
@@ -0,0 +1,52 @@
import { useEffect, useRef, useState } from 'react';
import { isTypingTarget } from '@/lib/keyboard';
interface UseTapGestureOptions {
/** Detected against KeyboardEvent.key. */
targetKey: string;
/** Optional KeyboardEvent.code fallback (e.g. 'Backquote'). */
targetCode?: string;
/** Taps required before `satisfied` flips true. Defaults to 1. */
taps?: number;
/** Fires on each counted tap, with the new running count. */
onTap?: (count: number) => void;
}
/**
* Counts discrete taps of a single key, ignoring auto-repeat. Used both for the
* one-tap audio/video toggle and the two-tap start/stop demonstration.
*/
export function useTapGesture({
targetKey,
targetCode,
taps = 1,
onTap,
}: UseTapGestureOptions) {
const [count, setCount] = useState(0);
const onTapRef = useRef(onTap);
useEffect(() => {
onTapRef.current = onTap;
});
useEffect(() => {
const matches = (e: KeyboardEvent) =>
e.key === targetKey || (targetCode != null && e.code === targetCode);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.repeat || isTypingTarget(e) || !matches(e)) return;
e.preventDefault();
setCount((prev) => {
if (prev >= taps) return prev;
const next = prev + 1;
onTapRef.current?.(next);
return next;
});
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [targetKey, targetCode, taps]);
return { taps: count, satisfied: count >= taps };
}
@@ -230,7 +230,7 @@ function ContainerControls({
}) { }) {
const requestIntent = useComposeIntentStore((s) => s.request); const requestIntent = useComposeIntentStore((s) => s.request);
return ( return (
<div className="flex items-center gap-4 text-sm text-white/50"> <div className="text-muted-foreground flex items-center gap-4 text-sm">
<KeyHint keys={['↑↓', 'Enter']}>navigate</KeyHint> <KeyHint keys={['↑↓', 'Enter']}>navigate</KeyHint>
<KeyHint keys="19">jump</KeyHint> <KeyHint keys="19">jump</KeyHint>
<VideoAudioToggle /> <VideoAudioToggle />
@@ -38,14 +38,16 @@ export function DeletedParticleView({
}, [paused, onEnded, particle.id]); }, [paused, onEnded, particle.id]);
return ( return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8"> <div className="flex h-full w-full items-center justify-center px-8">
<div className="flex flex-col items-center gap-3 text-center"> <div className="flex flex-col items-center gap-3 text-center">
<Trash2 className="text-white/40 size-6" /> <Trash2 className="text-muted-foreground size-6" />
<p className="text-white/70 text-base font-medium"> <p className="text-foreground text-base font-medium">
This particle was deleted This particle was deleted
</p> </p>
{deleter && ( {deleter && (
<p className="text-white/40 text-xs">by {deleter.displayName}</p> <p className="text-muted-foreground text-xs">
by {deleter.displayName}
</p>
)} )}
</div> </div>
</div> </div>
@@ -141,7 +141,7 @@ export const MediaParticleView = forwardRef<
if (isAudio) { if (isAudio) {
return ( return (
<div className="relative flex h-full w-full items-center justify-center bg-black/90"> <div className="bg-scrim/90 relative flex h-full w-full items-center justify-center">
<audio <audio
ref={(el) => { ref={(el) => {
audioRef.current = el; audioRef.current = el;
@@ -186,7 +186,7 @@ export const MediaParticleView = forwardRef<
playsInline playsInline
onEnded={onEnded} onEnded={onEnded}
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
className="h-full w-full object-contain bg-black" className="bg-black h-full w-full object-contain"
/> />
{transcript && ( {transcript && (
@@ -63,7 +63,7 @@ function ImageAttachment({
); );
if (isLoading || !url) { if (isLoading || !url) {
return <Skeleton className="h-20 w-20 shrink-0 rounded-lg bg-white/10" />; return <Skeleton className="h-20 w-20 shrink-0 rounded-lg" />;
} }
const handleDownload = (e: React.MouseEvent) => { const handleDownload = (e: React.MouseEvent) => {
@@ -78,7 +78,7 @@ function ImageAttachment({
e.stopPropagation(); e.stopPropagation();
onPreview(); onPreview();
}} }}
className="group relative block h-30 w-40 shrink-0 overflow-hidden rounded-lg bg-white/10" className="bg-muted group relative block h-30 w-40 shrink-0 overflow-hidden rounded-lg"
> >
<img <img
src={url} src={url}
@@ -89,7 +89,7 @@ function ImageAttachment({
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={handleDownload} onClick={handleDownload}
className="absolute bottom-1 right-1 rounded-full bg-black/60 p-1 text-white/70 opacity-0 transition-opacity hover:text-white group-hover:opacity-100" className="bg-scrim/60 absolute bottom-1 right-1 rounded-full p-1 text-white/80 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
> >
<Download className="size-3.5" /> <Download className="size-3.5" />
</span> </span>
@@ -123,14 +123,14 @@ function FileAttachment({
<div <div
role="button" role="button"
onClick={handleOpen} onClick={handleOpen}
className="flex shrink-0 cursor-pointer flex-col gap-1.5 rounded-lg bg-white/10 px-3 py-2 transition-colors hover:bg-white/15" className="bg-muted hover:bg-accent flex shrink-0 cursor-pointer flex-col gap-1.5 rounded-lg px-3 py-2 transition-colors"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FileIcon className="size-4 shrink-0 text-white/60" /> <FileIcon className="text-muted-foreground size-4 shrink-0" />
<span className="max-w-[10rem] truncate text-xs font-medium text-white/90"> <span className="text-foreground max-w-[10rem] truncate text-xs font-medium">
{particle.properties.filename} {particle.properties.filename}
</span> </span>
<span className="text-[10px] text-white/40"> <span className="text-muted-foreground text-[10px]">
{formatFileSize(particle.properties.size_bytes)} {formatFileSize(particle.properties.size_bytes)}
</span> </span>
</div> </div>
@@ -138,7 +138,7 @@ function FileAttachment({
<Button <Button
variant="ghost" variant="ghost"
size="xs" size="xs"
className="text-white/70 hover:bg-white/10 hover:text-white" className="text-muted-foreground"
onClick={handleOpen} onClick={handleOpen}
> >
<ExternalLink data-icon="inline-start" /> <ExternalLink data-icon="inline-start" />
@@ -147,7 +147,7 @@ function FileAttachment({
<Button <Button
variant="ghost" variant="ghost"
size="xs" size="xs"
className="text-white/70 hover:bg-white/10 hover:text-white" className="text-muted-foreground"
onClick={handleDownload} onClick={handleDownload}
> >
<Download data-icon="inline-start" /> <Download data-icon="inline-start" />
@@ -177,7 +177,7 @@ function CompactAttachmentItem({
e.stopPropagation(); e.stopPropagation();
openParticle(particle, index, url, onPreview); openParticle(particle, index, url, onPreview);
}} }}
className="flex w-full items-center gap-2 rounded-md bg-white/10 px-2.5 py-1.5 text-left transition-colors hover:bg-white/15" className="bg-muted hover:bg-accent flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left transition-colors"
> >
{isImage ? ( {isImage ? (
url ? ( url ? (
@@ -187,15 +187,15 @@ function CompactAttachmentItem({
className="size-5 shrink-0 rounded object-cover" className="size-5 shrink-0 rounded object-cover"
/> />
) : ( ) : (
<ImageIcon className="size-4 shrink-0 text-white/50" /> <ImageIcon className="text-muted-foreground size-4 shrink-0" />
) )
) : ( ) : (
<FileIcon className="size-4 shrink-0 text-white/50" /> <FileIcon className="text-muted-foreground size-4 shrink-0" />
)} )}
<span className="min-w-0 truncate text-xs text-white/80"> <span className="text-foreground min-w-0 truncate text-xs">
{particle.properties.filename} {particle.properties.filename}
</span> </span>
<span className="shrink-0 text-[10px] text-white/40"> <span className="text-muted-foreground shrink-0 text-[10px]">
{formatFileSize(particle.properties.size_bytes)} {formatFileSize(particle.properties.size_bytes)}
</span> </span>
</button> </button>
@@ -191,7 +191,7 @@ const StreamRow = memo(function StreamRow({
)} )}
> >
{shortcutKey && ( {shortcutKey && (
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground"> <kbd className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded font-mono text-xs">
{shortcutKey} {shortcutKey}
</kbd> </kbd>
)} )}
@@ -220,8 +220,8 @@ const StreamRow = memo(function StreamRow({
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
{hasActiveHuddle && ( {hasActiveHuddle && (
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5"> <span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
<Headphones className="size-3 text-red-400" /> <Headphones className="size-3 text-red-600 dark:text-red-400" />
<span className="text-[10px] font-medium text-red-400"> <span className="text-[10px] font-medium text-red-600 dark:text-red-400">
{huddleCount} {huddleCount}
</span> </span>
</span> </span>
@@ -298,7 +298,7 @@ const FolderRow = memo(function FolderRow({
)} )}
> >
{shortcutKey && ( {shortcutKey && (
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground"> <kbd className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded font-mono text-xs">
{shortcutKey} {shortcutKey}
</kbd> </kbd>
)} )}
@@ -356,7 +356,7 @@ const LeafRow = memo(function LeafRow({
)} )}
> >
{shortcutKey && ( {shortcutKey && (
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground"> <kbd className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded font-mono text-xs">
{shortcutKey} {shortcutKey}
</kbd> </kbd>
)} )}
@@ -69,9 +69,9 @@ function MediaPreview({
} }
return ( return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-black/90"> <div className="bg-muted text-muted-foreground flex h-full w-full flex-col items-center justify-center gap-2">
<Mic className="h-8 w-8 text-white/60" /> <Mic className="h-8 w-8" />
<span className="font-mono text-xs text-white/50">{durationLabel}</span> <span className="font-mono text-xs">{durationLabel}</span>
</div> </div>
); );
} }
@@ -103,8 +103,8 @@ function VideoThumbnail({
if (error) { if (error) {
return ( return (
<div className="flex h-full w-full items-center justify-center bg-black/80"> <div className="bg-muted flex h-full w-full items-center justify-center">
<Video className="h-8 w-8 text-white/40" /> <Video className="text-muted-foreground h-8 w-8" />
</div> </div>
); );
} }
@@ -114,7 +114,7 @@ function VideoThumbnail({
} }
return ( return (
<div className="relative h-full w-full bg-black"> <div className="bg-muted relative h-full w-full">
<video <video
src={`${url}#t=2`} src={`${url}#t=2`}
preload="metadata" preload="metadata"
@@ -122,7 +122,7 @@ function VideoThumbnail({
playsInline playsInline
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
<span className="absolute right-1.5 bottom-1.5 rounded bg-black/70 px-1.5 py-0.5 font-mono text-[10px] text-white/80"> <span className="bg-scrim/70 absolute bottom-1.5 right-1.5 rounded px-1.5 py-0.5 font-mono text-[10px] text-white">
{duration} {duration}
</span> </span>
</div> </div>
@@ -138,7 +138,7 @@ function LeafParticleView({
})(); })();
return ( return (
<div className="min-h-0 flex-1 bg-black text-white [--stream-safe-top:2rem] [--stream-safe-bottom:2rem]"> <div className="bg-background min-h-0 flex-1 [--stream-safe-top:2rem] [--stream-safe-bottom:2rem]">
{content} {content}
</div> </div>
); );
@@ -77,10 +77,10 @@ export function PlaybackPageIndicator({
className="group relative block h-3 w-full" className="group relative block h-3 w-full"
> >
{/* Dim track */} {/* Dim track */}
<div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/30 transition-all group-hover:h-1.5" /> <div className="bg-muted-foreground/30 absolute inset-x-0 bottom-0 h-[3px] transition-all group-hover:h-1.5" />
{/* Fill */} {/* Fill */}
<div <div
className="absolute left-0 bottom-0 h-[3px] bg-white/90 transition-all group-hover:h-1.5" className="bg-foreground absolute bottom-0 left-0 h-[3px] transition-all group-hover:h-1.5"
style={{ style={{
width: width:
i < current i < current
@@ -107,7 +107,7 @@ export function PlaybackPageIndicator({
)} )}
</div> </div>
{paginated && showTracks && current >= 0 && ( {paginated && showTracks && current >= 0 && (
<div className="pointer-events-none pt-1 text-center text-[10px] font-medium tabular-nums tracking-wide text-white/40"> <div className="text-muted-foreground pointer-events-none pt-1 text-center text-[10px] font-medium tabular-nums tracking-wide">
{current + 1} / {total} {current + 1} / {total}
</div> </div>
)} )}
@@ -141,7 +141,7 @@ function GhostStub({
className="group relative block h-3 w-2 shrink-0" className="group relative block h-3 w-2 shrink-0"
aria-label="Jump to adjacent page" aria-label="Jump to adjacent page"
> >
<div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/15 transition-all group-hover:h-1.5 group-hover:bg-white/40" /> <div className="bg-muted-foreground/20 group-hover:bg-muted-foreground/40 absolute inset-x-0 bottom-0 h-[3px] transition-all group-hover:h-1.5" />
</button> </button>
); );
} }
@@ -166,7 +166,7 @@ function SegmentPresenceAvatars({
className={ className={
onlineHumanIds?.has(human.humanId) onlineHumanIds?.has(human.humanId)
? 'ring-2 ring-green-500' ? 'ring-2 ring-green-500'
: 'ring-1 ring-black/50' : 'ring-1 ring-background'
} }
avatarObjectId={human.avatarObjectId} avatarObjectId={human.avatarObjectId}
initials={human.emailPrefix.slice(0, 2).toUpperCase()} initials={human.emailPrefix.slice(0, 2).toUpperCase()}
@@ -178,7 +178,9 @@ function SegmentPresenceAvatars({
</Tooltip> </Tooltip>
))} ))}
{overflow > 0 && ( {overflow > 0 && (
<span className="text-[10px] text-white/70 pl-1">+{overflow}</span> <span className="text-muted-foreground pl-1 text-[10px]">
+{overflow}
</span>
)} )}
</div> </div>
); );
@@ -77,12 +77,12 @@ export function ReactionBar({
className={cn( className={cn(
'flex items-center gap-1 rounded-full px-2 py-0.5 text-xs backdrop-blur-sm transition-colors', 'flex items-center gap-1 rounded-full px-2 py-0.5 text-xs backdrop-blur-sm transition-colors',
isMine isMine
? 'bg-white/20 ring-1 ring-white/40' ? 'bg-primary/15 text-foreground ring-1 ring-primary/40'
: 'bg-black/40 hover:bg-black/50', : 'bg-card/70 text-foreground border border-border hover:bg-accent',
)} )}
> >
<span className="text-sm">{emoji}</span> <span className="text-sm">{emoji}</span>
<span className="text-white/80">{reactors.length}</span> <span className="text-muted-foreground">{reactors.length}</span>
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="left" className="text-xs"> <TooltipContent side="left" className="text-xs">
@@ -109,8 +109,8 @@ export function ReactionBar({
className={cn( className={cn(
'flex max-w-[200px] items-center gap-1.5 rounded-full py-0.5 pl-0.5 pr-2.5 text-xs backdrop-blur-sm transition-colors', 'flex max-w-[200px] items-center gap-1.5 rounded-full py-0.5 pl-0.5 pr-2.5 text-xs backdrop-blur-sm transition-colors',
isMine isMine
? 'bg-white/20 ring-1 ring-white/40' ? 'bg-primary/15 text-foreground ring-1 ring-primary/40'
: 'bg-black/40 hover:bg-black/50', : 'bg-card/70 text-foreground border border-border hover:bg-accent',
)} )}
> >
<HumanAvatar <HumanAvatar
@@ -118,11 +118,11 @@ export function ReactionBar({
className="shrink-0" className="shrink-0"
avatarObjectId={firstReactor.avatarObjectId} avatarObjectId={firstReactor.avatarObjectId}
initials={firstReactor.initials} initials={firstReactor.initials}
fallbackClassName="bg-white/15 text-[9px] font-medium text-white" fallbackClassName="bg-muted text-muted-foreground text-[9px] font-medium"
/> />
<span className="truncate text-white/90">{text}</span> <span className="truncate text-foreground">{text}</span>
{reactors.length > 1 && ( {reactors.length > 1 && (
<span className="shrink-0 text-white/60"> <span className="text-muted-foreground shrink-0">
{reactors.length} {reactors.length}
</span> </span>
)} )}
@@ -154,7 +154,7 @@ export function ReactionBar({
{/* Picker / actions */} {/* Picker / actions */}
{expanded ? ( {expanded ? (
<div className="flex flex-col items-center gap-0.5 rounded-full bg-black/40 px-0.5 py-1.5 backdrop-blur-sm"> <div className="bg-card/80 border-border flex flex-col items-center gap-0.5 rounded-full border px-0.5 py-1.5 backdrop-blur-sm">
{REACTION_EMOJIS.map((emoji) => { {REACTION_EMOJIS.map((emoji) => {
if (activeEmojis.includes(emoji)) return null; if (activeEmojis.includes(emoji)) return null;
return ( return (
@@ -164,7 +164,7 @@ export function ReactionBar({
e.stopPropagation(); e.stopPropagation();
handleToggle(emoji); handleToggle(emoji);
}} }}
className="rounded-full px-0.5 py-1 text-sm transition-colors hover:bg-white/15" className="hover:bg-accent rounded-full px-0.5 py-1 text-sm transition-colors"
> >
{emoji} {emoji}
</button> </button>
@@ -175,9 +175,9 @@ export function ReactionBar({
e.stopPropagation(); e.stopPropagation();
setExpanded(false); setExpanded(false);
}} }}
className="flex size-5 items-center justify-center rounded-full transition-colors hover:bg-white/15" className="hover:bg-accent flex size-5 items-center justify-center rounded-full transition-colors"
> >
<X className="size-3 text-white/60" /> <X className="text-muted-foreground size-3" />
</button> </button>
</div> </div>
) : ( ) : (
@@ -189,14 +189,14 @@ export function ReactionBar({
e.stopPropagation(); e.stopPropagation();
onOpenTextReaction(); onOpenTextReaction();
}} }}
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50" className="bg-card/70 border-border hover:bg-accent flex size-6 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
> >
<Type className="size-3 text-white/60" /> <Type className="text-muted-foreground size-3" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="left" className="text-xs"> <TooltipContent side="left" className="text-xs">
Quick reply{' '} Quick reply{' '}
<kbd className="ml-1 rounded bg-white/10 px-1 font-mono text-[10px]"> <kbd className="bg-muted ml-1 rounded px-1 font-mono text-[10px]">
R R
</kbd> </kbd>
</TooltipContent> </TooltipContent>
@@ -206,9 +206,9 @@ export function ReactionBar({
e.stopPropagation(); e.stopPropagation();
setExpanded(true); setExpanded(true);
}} }}
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50" className="bg-card/70 border-border hover:bg-accent flex size-6 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
> >
<Plus className="size-3 text-white/60" /> <Plus className="text-muted-foreground size-3" />
</button> </button>
</div> </div>
)} )}
@@ -56,17 +56,17 @@ export function RenameStreamOverlay({
return createPortal( return createPortal(
<div className="fixed inset-0 z-[100]"> <div className="fixed inset-0 z-[100]">
<div <div
className="absolute inset-0 bg-black/60 backdrop-blur-sm" className="absolute inset-0 bg-scrim/60 backdrop-blur-sm"
onClick={onClose} onClick={onClose}
/> />
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl"> <div className="bg-popover/95 text-popover-foreground border-border absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border p-6 shadow-2xl backdrop-blur-xl">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">Rename stream</h2> <h2 className="text-sm font-semibold">Rename stream</h2>
<KeyHint <KeyHint
keys="Esc" keys="Esc"
onClick={onClose} onClick={onClose}
title="Close (or press Esc)" title="Close (or press Esc)"
className="text-xs text-white/30" className="text-muted-foreground text-xs"
> >
to close to close
</KeyHint> </KeyHint>
@@ -85,7 +85,6 @@ export function RenameStreamOverlay({
} }
}} }}
placeholder="Stream name" placeholder="Stream name"
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
/> />
<div className="mt-4 flex items-center justify-end gap-2"> <div className="mt-4 flex items-center justify-end gap-2">
@@ -61,7 +61,7 @@ export function BottomBar({
<div className="flex items-center justify-center px-3 pt-2 gap-2"> <div className="flex items-center justify-center px-3 pt-2 gap-2">
{exitRemainingMs !== null && ( {exitRemainingMs !== null && (
<div className="flex justify-center"> <div className="flex justify-center">
<span className="rounded-full bg-black/30 px-2 text-xs text-white/70 backdrop-blur-sm"> <span className="bg-card/70 text-muted-foreground border-border rounded-full border px-2 text-xs backdrop-blur-sm">
Closing in {Math.ceil(exitRemainingMs / 1000)}s Closing in {Math.ceil(exitRemainingMs / 1000)}s
</span> </span>
</div> </div>
@@ -91,7 +91,7 @@ export function StreamViewControls({
}) { }) {
const requestIntent = useComposeIntentStore((s) => s.request); const requestIntent = useComposeIntentStore((s) => s.request);
return ( return (
<div className="flex items-center gap-4 text-sm text-white/50"> <div className="text-muted-foreground flex items-center gap-4 text-sm">
{showEscape && ( {showEscape && (
<KeyHint <KeyHint
keys="Esc" keys="Esc"
@@ -39,17 +39,17 @@ export function StreamListSidebar({
}, [currentIndex]); }, [currentIndex]);
return ( return (
<aside className="dark flex max-w-60 shrink-0 flex-col border-l border-white/10 bg-zinc-950"> <aside className="bg-sidebar text-sidebar-foreground border-border flex w-60 shrink-0 flex-col overflow-hidden border-l">
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-4 py-3"> <div className="border-border flex shrink-0 items-center gap-2 border-b px-4 py-3">
<List className="size-3.5 text-white/40" /> <List className="text-muted-foreground size-3.5" />
<span className="truncate text-sm font-medium text-white/90 mr-auto"> <span className="mr-auto truncate text-sm font-medium">
{items.length} messages {items.length} messages
</span> </span>
<KeyHint <KeyHint
keys="L" keys="L"
onClick={onToggle} onClick={onToggle}
title="Hide list (or press L)" title="Hide list (or press L)"
className="text-xs text-white/40" className="text-muted-foreground text-xs"
> >
to close to close
</KeyHint> </KeyHint>
@@ -72,7 +72,7 @@ export function StreamListSidebar({
</div> </div>
))} ))}
{items.length === 0 && ( {items.length === 0 && (
<p className="px-2 py-8 text-center text-sm text-white/40"> <p className="text-muted-foreground px-2 py-8 text-center text-sm">
No particles in this stream yet. No particles in this stream yet.
</p> </p>
)} )}
@@ -105,8 +105,8 @@ function ChatRow({
if (e.key === 'Enter') onClick(); if (e.key === 'Enter') onClick();
}} }}
className={cn( className={cn(
'flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors', 'flex cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors',
isSelected ? 'bg-white/10' : 'hover:bg-white/5', isSelected ? 'bg-accent' : 'hover:bg-accent/50',
)} )}
> >
<HumanAvatar <HumanAvatar
@@ -114,14 +114,14 @@ function ChatRow({
className="mt-0.5 shrink-0" className="mt-0.5 shrink-0"
initials={sender.initials} initials={sender.initials}
avatarObjectId={sender.avatarObjectId} avatarObjectId={sender.avatarObjectId}
fallbackClassName="bg-white/10 text-white/80 text-[10px] font-medium" fallbackClassName="bg-muted text-muted-foreground text-[10px] font-medium"
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1 overflow-hidden">
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-baseline justify-between gap-2">
<span className="truncate text-xs font-semibold text-white/90"> <span className="truncate text-xs font-semibold">
{sender.displayName} {sender.displayName}
</span> </span>
<span className="shrink-0 text-[10px] text-white/35"> <span className="text-muted-foreground shrink-0 text-[10px]">
<RelativeTimestamp date={particle.created_at} /> <RelativeTimestamp date={particle.created_at} />
</span> </span>
</div> </div>
@@ -134,14 +134,16 @@ function ChatRow({
function ChatRowContent({ particle }: { particle: Particle }) { function ChatRowContent({ particle }: { particle: Particle }) {
if (isParticleDeleted(particle)) { if (isParticleDeleted(particle)) {
return ( return (
<p className="text-xs italic text-white/35">This particle was deleted</p> <p className="text-muted-foreground text-xs italic">
This particle was deleted
</p>
); );
} }
switch (particle.type) { switch (particle.type) {
case 'text': case 'text':
return ( return (
<p className="line-clamp-2 text-xs leading-relaxed whitespace-pre-line text-white/70"> <p className="text-muted-foreground line-clamp-2 whitespace-pre-line text-xs leading-relaxed [overflow-wrap:anywhere]">
{particle.properties.content} {particle.properties.content}
</p> </p>
); );
@@ -166,13 +168,13 @@ function ChatRowContent({ particle }: { particle: Particle }) {
: ''; : '';
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs text-white/70"> <span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Icon className="size-3.5 shrink-0 text-white/50" /> <Icon className="text-muted-foreground size-3.5 shrink-0" />
{label} {label}
{duration} {duration}
</span> </span>
{transcript && ( {transcript && (
<p className="line-clamp-2 text-xs leading-relaxed text-white/45"> <p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{transcript} {transcript}
</p> </p>
)} )}
@@ -181,8 +183,8 @@ function ChatRowContent({ particle }: { particle: Particle }) {
} }
case 'file': case 'file':
return ( return (
<span className="flex items-center gap-1.5 text-xs text-white/70"> <span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<FileText className="size-3.5 shrink-0 text-white/50" /> <FileText className="text-muted-foreground size-3.5 shrink-0" />
<span className="truncate">{particle.properties.filename}</span> <span className="truncate">{particle.properties.filename}</span>
</span> </span>
); );
@@ -191,21 +193,26 @@ function ChatRowContent({ particle }: { particle: Particle }) {
const doneCount = checklist.filter((item) => item.done).length; const doneCount = checklist.filter((item) => item.done).length;
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs text-white/70"> <span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<CircleCheck <CircleCheck
className={cn( className={cn(
'size-3.5 shrink-0', 'size-3.5 shrink-0',
done ? 'text-emerald-400' : 'text-white/50', done
? 'text-emerald-600 dark:text-emerald-400'
: 'text-muted-foreground',
)} )}
/> />
<span <span
className={cn('truncate', done && 'text-white/40 line-through')} className={cn(
'truncate',
done && 'text-muted-foreground line-through',
)}
> >
{title} {title}
</span> </span>
</span> </span>
{checklist.length > 0 && ( {checklist.length > 0 && (
<span className="pl-5 text-[10px] text-white/40"> <span className="text-muted-foreground pl-5 text-[10px]">
{doneCount} / {checklist.length} subtasks {doneCount} / {checklist.length} subtasks
</span> </span>
)} )}
@@ -214,11 +221,11 @@ function ChatRowContent({ particle }: { particle: Particle }) {
} }
case 'paper': case 'paper':
return ( return (
<p className="truncate text-xs text-white/70"> <p className="text-muted-foreground truncate text-xs">
{particle.properties.title} {particle.properties.title}
</p> </p>
); );
default: default:
return <p className="text-xs text-white/45">{particle.type}</p>; return <p className="text-muted-foreground text-xs">{particle.type}</p>;
} }
} }
@@ -97,18 +97,18 @@ export function StreamMembersOverlay({
return createPortal( return createPortal(
<div className="fixed inset-0 z-[100]"> <div className="fixed inset-0 z-[100]">
<div <div
className="absolute inset-0 bg-black/60 backdrop-blur-sm" className="bg-scrim/60 absolute inset-0 backdrop-blur-sm"
onClick={onClose} onClick={onClose}
/> />
<div className="absolute left-1/2 top-1/2 flex max-h-[80vh] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl"> <div className="bg-popover/95 text-popover-foreground border-border absolute left-1/2 top-1/2 flex max-h-[80vh] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border p-6 shadow-2xl backdrop-blur-xl">
{/* Header */} {/* Header */}
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">Members</h2> <h2 className="text-sm font-semibold">Members</h2>
<KeyHint <KeyHint
keys="Esc" keys="Esc"
onClick={onClose} onClick={onClose}
title="Close (or press Esc)" title="Close (or press Esc)"
className="text-xs text-white/30" className="text-muted-foreground text-xs"
> >
to close to close
</KeyHint> </KeyHint>
@@ -116,11 +116,11 @@ export function StreamMembersOverlay({
{/* Visibility */} {/* Visibility */}
<section className="mb-4"> <section className="mb-4">
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30"> <h3 className="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
Visibility Visibility
</h3> </h3>
{isCreator ? ( {isCreator ? (
<div className="grid grid-cols-2 gap-1 rounded-lg bg-white/5 p-1"> <div className="bg-muted grid grid-cols-2 gap-1 rounded-lg p-1">
<VisibilityPill <VisibilityPill
active={visibility.mode === 'network'} active={visibility.mode === 'network'}
icon={<Globe className="size-3.5" />} icon={<Globe className="size-3.5" />}
@@ -135,15 +135,15 @@ export function StreamMembersOverlay({
/> />
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2 text-sm text-white/70"> <div className="text-foreground flex items-center gap-2 text-sm">
{visibility.mode === 'network' ? ( {visibility.mode === 'network' ? (
<> <>
<Globe className="size-3.5 text-white/40" /> <Globe className="text-muted-foreground size-3.5" />
<span>Everyone in {network?.name ?? 'network'}</span> <span>Everyone in {network?.name ?? 'network'}</span>
</> </>
) : ( ) : (
<> <>
<Lock className="size-3.5 text-white/40" /> <Lock className="text-muted-foreground size-3.5" />
<span>{memberIds.length} specific people</span> <span>{memberIds.length} specific people</span>
</> </>
)} )}
@@ -153,9 +153,11 @@ export function StreamMembersOverlay({
{/* Member list */} {/* Member list */}
<section className="flex min-h-0 flex-1 flex-col"> <section className="flex min-h-0 flex-1 flex-col">
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30"> <h3 className="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
{visibility.mode === 'network' ? 'Has access' : 'People'}{' '} {visibility.mode === 'network' ? 'Has access' : 'People'}{' '}
<span className="ml-1 text-white/20">{memberIds.length}</span> <span className="text-muted-foreground ml-1">
{memberIds.length}
</span>
</h3> </h3>
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
<ul className="flex flex-col gap-0.5 pr-2"> <ul className="flex flex-col gap-0.5 pr-2">
@@ -167,7 +169,7 @@ export function StreamMembersOverlay({
return ( return (
<li <li
key={id} key={id}
className="group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm text-white/70" className="text-foreground group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm"
> >
<HumanAvatar <HumanAvatar
size="sm" size="sm"
@@ -178,13 +180,13 @@ export function StreamMembersOverlay({
<span <span
className={cn( className={cn(
'flex-1 truncate', 'flex-1 truncate',
!display.exists && 'italic text-white/40', !display.exists && 'text-muted-foreground italic',
)} )}
> >
{display.displayName} {display.displayName}
</span> </span>
{isCreatorRow && ( {isCreatorRow && (
<span className="text-[10px] uppercase tracking-wider text-white/30"> <span className="text-muted-foreground text-[10px] uppercase tracking-wider">
Creator Creator
</span> </span>
)} )}
@@ -192,7 +194,7 @@ export function StreamMembersOverlay({
<button <button
type="button" type="button"
onClick={() => removeMember(id)} onClick={() => removeMember(id)}
className="rounded p-1 text-white/30 opacity-0 transition-opacity hover:bg-white/10 hover:text-white/70 group-hover:opacity-100" className="text-muted-foreground hover:bg-accent hover:text-foreground rounded p-1 opacity-0 transition-opacity group-hover:opacity-100"
aria-label={`Remove ${display.displayName}`} aria-label={`Remove ${display.displayName}`}
> >
<X className="size-3.5" /> <X className="size-3.5" />
@@ -209,8 +211,8 @@ export function StreamMembersOverlay({
{isCreator && {isCreator &&
visibility.mode === 'custom' && visibility.mode === 'custom' &&
availableToAdd.length > 0 && ( availableToAdd.length > 0 && (
<section className="mt-4 border-t border-white/5 pt-4"> <section className="border-border mt-4 border-t pt-4">
<h3 className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-white/30"> <h3 className="text-muted-foreground mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest">
<UserPlus className="size-3" /> <UserPlus className="size-3" />
Add people Add people
</h3> </h3>
@@ -222,7 +224,7 @@ export function StreamMembersOverlay({
type="button" type="button"
onClick={() => addMember(human.id)} onClick={() => addMember(human.id)}
className={cn( className={cn(
'flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm text-white/70 transition-colors hover:bg-white/5', 'text-foreground hover:bg-accent flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm transition-colors',
)} )}
> >
<HumanAvatar <HumanAvatar
@@ -234,7 +236,7 @@ export function StreamMembersOverlay({
<span className="flex-1 truncate"> <span className="flex-1 truncate">
{human.email_prefix} {human.email_prefix}
</span> </span>
<UserPlus className="size-3.5 text-white/30" /> <UserPlus className="text-muted-foreground size-3.5" />
</button> </button>
</li> </li>
))} ))}
@@ -246,7 +248,7 @@ export function StreamMembersOverlay({
{isCreator && {isCreator &&
visibility.mode === 'custom' && visibility.mode === 'custom' &&
availableToAdd.length === 0 && ( availableToAdd.length === 0 && (
<p className="mt-4 text-center text-xs text-white/30"> <p className="text-muted-foreground mt-4 text-center text-xs">
<Users className="mr-1 inline size-3" /> <Users className="mr-1 inline size-3" />
Everyone in the network is already a member Everyone in the network is already a member
</p> </p>
@@ -275,8 +277,8 @@ function VisibilityPill({
className={cn( className={cn(
'flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors', 'flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors',
active active
? 'bg-white/10 text-white/90' ? 'bg-background text-foreground shadow-sm'
: 'text-white/50 hover:text-white/80', : 'text-muted-foreground hover:text-foreground',
)} )}
> >
{icon} {icon}
@@ -92,7 +92,7 @@ export function TopBar({
<div className="drag-region flex flex-row px-2 gap-1 items-center"> <div className="drag-region flex flex-row px-2 gap-1 items-center">
<WindowControls /> <WindowControls />
<Breadcrumb className="no-drag rounded-full bg-black/30 backdrop-blur-sm px-3 py-1 mx-auto"> <Breadcrumb className="no-drag bg-card/70 border-border mx-auto rounded-full border px-3 py-1 backdrop-blur-sm">
<BreadcrumbList> <BreadcrumbList>
{streamParticle && ( {streamParticle && (
<> <>
@@ -139,7 +139,7 @@ export function TopBar({
size="sm" size="sm"
avatarObjectId={display.avatarObjectId} avatarObjectId={display.avatarObjectId}
initials={display.initials} initials={display.initials}
fallbackClassName="bg-red-500/30 text-[8px] text-red-200" fallbackClassName="bg-red-500/30 text-[8px] text-red-700 dark:text-red-200"
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{display.email}</TooltipContent> <TooltipContent>{display.email}</TooltipContent>
@@ -147,12 +147,14 @@ export function TopBar({
); );
})} })}
</AvatarGroup> </AvatarGroup>
<span className="text-xs font-medium text-red-200">Join</span> <span className="text-xs font-medium text-red-600 dark:text-red-200">
Join
</span>
</button> </button>
)} )}
{!isStreamOpen(streamParticle) && ( {!isStreamOpen(streamParticle) && (
<span className="no-drag text-muted-foreground flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs backdrop-blur-sm"> <span className="no-drag text-muted-foreground bg-muted border-border flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs backdrop-blur-sm">
<CircleCheckBig className="size-3" /> <CircleCheckBig className="size-3" />
Closed Closed
</span> </span>
@@ -280,16 +282,16 @@ function MembersIndicator({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
onClick={onClick} onClick={onClick}
className="no-drag flex items-center gap-1.5 rounded-full bg-white/5 px-2 py-1 text-xs text-white/70 backdrop-blur-sm transition-colors hover:bg-white/10" className="no-drag bg-card/70 text-muted-foreground border-border hover:bg-accent flex items-center gap-1.5 rounded-full border px-2 py-1 text-xs backdrop-blur-sm transition-colors"
> >
{isNetworkWide ? ( {isNetworkWide ? (
<> <>
<Globe className="size-3 text-white/50" /> <Globe className="text-muted-foreground size-3" />
<span>Everyone</span> <span>Everyone</span>
</> </>
) : isPrivate ? ( ) : isPrivate ? (
<> <>
<Lock className="size-3 text-white/50" /> <Lock className="text-muted-foreground size-3" />
<span>Private</span> <span>Private</span>
</> </>
) : ( ) : (
@@ -306,7 +308,7 @@ function MembersIndicator({
))} ))}
</AvatarGroup> </AvatarGroup>
{overflow > 0 && ( {overflow > 0 && (
<span className="text-white/50">+{overflow}</span> <span className="text-muted-foreground">+{overflow}</span>
)} )}
</> </>
)} )}
@@ -381,7 +381,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
if (children.length === 0) { if (children.length === 0) {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-4 bg-black text-white"> <div className="bg-background flex h-full flex-col items-center justify-center gap-4">
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
No particles in this stream yet No particles in this stream yet
</p> </p>
@@ -449,6 +449,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
paused={paused} paused={paused}
onEnded={handleParticleEnded} onEnded={handleParticleEnded}
onProgress={setProgress} onProgress={setProgress}
immersive
/> />
); );
default: default:
@@ -459,15 +460,15 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
} }
return ( return (
<div className="flex h-screen overflow-hidden bg-black"> <div className="bg-background flex h-screen overflow-hidden">
{/* Stream chrome — immersive playback column */} {/* Stream chrome — immersive playback column */}
<div <div
className="relative flex min-w-0 flex-1 flex-col overflow-hidden bg-black text-white [--stream-safe-top:4rem] [--stream-safe-bottom:5rem]" className="bg-background relative flex min-w-0 flex-1 flex-col overflow-hidden [--stream-safe-top:4rem] [--stream-safe-bottom:5rem]"
onMouseMove={handleMouseActivity} onMouseMove={handleMouseActivity}
onMouseLeave={() => setShowControls(false)} onMouseLeave={() => setShowControls(false)}
> >
{/* Top gradient safe zone */} {/* Top gradient safe zone */}
<div className="pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b from-black/60 to-transparent" /> <div className="from-background/80 pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b to-transparent" />
{/* TopBar — always visible */} {/* TopBar — always visible */}
<div className="z-10 absolute left-0 right-0 pt-2"> <div className="z-10 absolute left-0 right-0 pt-2">
@@ -487,7 +488,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
<div className="pointer-events-none absolute right-4 top-14 z-20 flex flex-col items-end gap-1.5"> <div className="pointer-events-none absolute right-4 top-14 z-20 flex flex-col items-end gap-1.5">
{fastPlayback && ( {fastPlayback && (
<div className="rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm"> <div className="bg-card/70 text-foreground border-border rounded-full border px-2.5 py-1 text-xs font-medium backdrop-blur-sm">
1.5x 1.5x
</div> </div>
)} )}
@@ -496,7 +497,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
type="button" type="button"
onClick={resume} onClick={resume}
title="Resume (or press Space)" title="Resume (or press Space)"
className="pointer-events-auto flex items-center gap-1.5 rounded-full bg-black/40 px-2.5 py-1 text-xs font-medium text-white/70 backdrop-blur-sm transition-colors hover:bg-black/60 hover:text-white" className="bg-card/70 text-muted-foreground border-border hover:bg-accent hover:text-foreground pointer-events-auto flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium backdrop-blur-sm transition-colors"
> >
<Play className="size-3 fill-current" /> <Play className="size-3 fill-current" />
Paused Paused
@@ -540,7 +541,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
/> />
{/* Bottom gradient safe zone for keyboard hints */} {/* Bottom gradient safe zone for keyboard hints */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t from-black/60 to-transparent" /> <div className="from-background/80 pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t to-transparent" />
{/* BottomBar — pinned visible while browsing, mouse-activity in player */} {/* BottomBar — pinned visible while browsing, mouse-activity in player */}
<BottomBar <BottomBar
@@ -28,6 +28,8 @@ import { useFixedDwell } from '@/hooks/use-fixed-dwell';
import { useLiveDraftField } from '@/hooks/use-live-draft-field'; import { useLiveDraftField } from '@/hooks/use-live-draft-field';
import { useSuspendPlayback } from '@/hooks/use-suspend-playback'; import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { resolveHumanDisplay } from '@/lib/humans'; import { resolveHumanDisplay } from '@/lib/humans';
import { isTypingTarget } from '@/lib/keyboard';
import { KeyHint } from '@/components/key-hint';
type TaskParticle = Extract<Particle, { type: 'task' }>; type TaskParticle = Extract<Particle, { type: 'task' }>;
@@ -37,11 +39,28 @@ interface TaskParticleViewProps {
paused: boolean; paused: boolean;
onEnded: () => void; onEnded: () => void;
onProgress?: (ratio: number) => void; onProgress?: (ratio: number) => void;
/**
* Enable focus mode while a field is focused: dim and blur the surrounding
* stream, and trap stream keys (Escape leaves the editor, navigation keys
* stay put). Off in the standalone leaf view, which has no stream chrome.
*/
immersive?: boolean;
} }
const DWELL_DURATION_S = 8; const DWELL_DURATION_S = 8;
const UNASSIGNED = 'unassigned'; const UNASSIGNED = 'unassigned';
// Stream-navigation keys to swallow while editing so they can't pull focus to
// another particle (only when focus isn't already in a text field).
const NAV_KEYS = new Set([
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
'l',
'L',
]);
function useParticleDocPath( function useParticleDocPath(
containerPath: ParticlePath, containerPath: ParticlePath,
particleId: string, particleId: string,
@@ -56,6 +75,7 @@ export function TaskParticleView({
paused, paused,
onEnded, onEnded,
onProgress, onProgress,
immersive = false,
}: TaskParticleViewProps) { }: TaskParticleViewProps) {
const { networkId } = parseParticlePath(containerPath); const { networkId } = parseParticlePath(containerPath);
const network = useNetwork(networkId); const network = useNetwork(networkId);
@@ -74,6 +94,33 @@ export function TaskParticleView({
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
useSuspendPlayback(editing, `task-edit-${particle.id}`); useSuspendPlayback(editing, `task-edit-${particle.id}`);
const exitFocus = useCallback(() => {
(document.activeElement as HTMLElement | null)?.blur();
}, []);
// While editing, the card acts like the app's other focus overlays: a
// capture-phase listener consumes Escape (which blurs the field — flushing
// the draft and resuming playback) and swallows stream-navigation keys, so
// the stream's own handlers never see them. Self-contained, so no editing
// state has to be threaded back up to the stream.
useEffect(() => {
if (!immersive || !editing) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
exitFocus();
} else if (NAV_KEYS.has(e.key) && !isTypingTarget(e)) {
// Arrows still move the caret inside text fields; only block them when
// focus is on a non-text control (checkbox, assignee select).
e.stopPropagation();
}
};
window.addEventListener('keydown', onKeyDown, { capture: true });
return () =>
window.removeEventListener('keydown', onKeyDown, { capture: true });
}, [immersive, editing, exitFocus]);
useFixedDwell({ useFixedDwell({
id: particle.id, id: particle.id,
durationS: DWELL_DURATION_S, durationS: DWELL_DURATION_S,
@@ -165,9 +212,21 @@ export function TaskParticleView({
const doneCount = checklist.filter((item) => item.done).length; const doneCount = checklist.filter((item) => item.done).length;
return ( return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]"> <div className="flex h-full w-full items-center justify-center px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
{immersive && editing && (
<div
className="animate-in fade-in-0 fixed inset-0 z-30 bg-scrim/50 backdrop-blur-md duration-200"
onMouseDown={exitFocus}
aria-hidden
/>
)}
<div <div
className="scrollbar-card flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-5 overflow-y-auto overscroll-contain rounded bg-white/10 p-[var(--message-card-padding)] backdrop-blur-md" className={cn(
'scrollbar-card bg-card text-card-foreground border-border flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-5 overflow-y-auto overscroll-contain rounded border p-[var(--message-card-padding)] transition-shadow',
immersive &&
editing &&
'relative z-40 shadow-2xl shadow-black/50 ring-1 ring-border',
)}
onFocusCapture={() => setEditing(true)} onFocusCapture={() => setEditing(true)}
onBlurCapture={(e) => { onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false); if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false);
@@ -177,7 +236,7 @@ export function TaskParticleView({
<Checkbox <Checkbox
checked={done} checked={done}
onCheckedChange={(checked) => handleToggleDone(checked === true)} onCheckedChange={(checked) => handleToggleDone(checked === true)}
className="mt-1.5 size-5 rounded-full border-white/40 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500" className="mt-1.5 size-5 rounded-full border-muted-foreground/40 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
aria-label={done ? 'Mark task as not done' : 'Mark task as done'} aria-label={done ? 'Mark task as not done' : 'Mark task as done'}
/> />
<input <input
@@ -187,8 +246,8 @@ export function TaskParticleView({
onBlur={titleField.onBlur} onBlur={titleField.onBlur}
placeholder="Task title" placeholder="Task title"
className={cn( className={cn(
'w-full bg-transparent text-2xl font-semibold text-white outline-none placeholder:text-white/30', 'text-foreground placeholder:text-muted-foreground w-full bg-transparent text-2xl font-semibold outline-none',
done && 'text-white/50 line-through', done && 'text-muted-foreground line-through',
)} )}
/> />
</div> </div>
@@ -199,12 +258,12 @@ export function TaskParticleView({
onFocus={notesField.onFocus} onFocus={notesField.onFocus}
onBlur={notesField.onBlur} onBlur={notesField.onBlur}
placeholder="Add notes…" placeholder="Add notes…"
className="min-h-16 resize-none border-none bg-transparent p-0 text-sm text-white/80 shadow-none placeholder:text-white/30 focus-visible:ring-0 dark:bg-transparent" className="text-foreground placeholder:text-muted-foreground min-h-16 resize-none border-none bg-transparent p-0 text-sm shadow-none focus-visible:ring-0 dark:bg-transparent"
/> />
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{checklist.length > 0 && ( {checklist.length > 0 && (
<span className="text-xs text-white/40"> <span className="text-muted-foreground text-xs">
{doneCount} / {checklist.length} done {doneCount} / {checklist.length} done
</span> </span>
)} )}
@@ -227,10 +286,7 @@ export function TaskParticleView({
value={assigned_to ?? UNASSIGNED} value={assigned_to ?? UNASSIGNED}
onValueChange={handleAssign} onValueChange={handleAssign}
> >
<SelectTrigger <SelectTrigger size="sm" className="w-fit gap-2">
size="sm"
className="w-fit gap-2 border-white/15 bg-white/5 text-white/80"
>
{assigned_to && assignee.exists && ( {assigned_to && assignee.exists && (
<HumanAvatar <HumanAvatar
size="sm" size="sm"
@@ -251,6 +307,16 @@ export function TaskParticleView({
</Select> </Select>
</div> </div>
</div> </div>
{immersive && editing && (
<KeyHint
keys="Esc"
onClick={exitFocus}
title="Finish editing (or press Esc)"
className="fixed bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 z-40 -translate-x-1/2 text-xs text-white/60"
>
to finish
</KeyHint>
)}
</div> </div>
); );
} }
@@ -276,7 +342,7 @@ function ChecklistItemRow({
<Checkbox <Checkbox
checked={item.done} checked={item.done}
onCheckedChange={(checked) => onToggle(checked === true)} onCheckedChange={(checked) => onToggle(checked === true)}
className="border-white/30 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500" className="border-muted-foreground/40 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
aria-label={ aria-label={
item.done ? 'Mark subtask as not done' : 'Mark subtask as done' item.done ? 'Mark subtask as not done' : 'Mark subtask as done'
} }
@@ -288,14 +354,14 @@ function ChecklistItemRow({
onBlur={textField.onBlur} onBlur={textField.onBlur}
placeholder="Subtask" placeholder="Subtask"
className={cn( className={cn(
'w-full bg-transparent text-sm text-white/90 outline-none placeholder:text-white/30', 'text-foreground placeholder:text-muted-foreground w-full bg-transparent text-sm outline-none',
item.done && 'text-white/40 line-through', item.done && 'text-muted-foreground line-through',
)} )}
/> />
<button <button
type="button" type="button"
onClick={onRemove} onClick={onRemove}
className="text-white/30 opacity-0 transition-opacity hover:text-white/70 group-hover/item:opacity-100" className="text-muted-foreground hover:text-foreground opacity-0 transition-opacity group-hover/item:opacity-100"
aria-label="Remove subtask" aria-label="Remove subtask"
> >
<X className="size-3.5" /> <X className="size-3.5" />
@@ -316,7 +382,7 @@ function AddChecklistItemRow({ onAdd }: { onAdd: (text: string) => void }) {
return ( return (
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<Plus className="size-4 text-white/30" /> <Plus className="text-muted-foreground size-4" />
<input <input
value={draft} value={draft}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
@@ -328,7 +394,7 @@ function AddChecklistItemRow({ onAdd }: { onAdd: (text: string) => void }) {
}} }}
onBlur={submit} onBlur={submit}
placeholder="Add subtask…" placeholder="Add subtask…"
className="w-full bg-transparent text-sm text-white/70 outline-none placeholder:text-white/30" className="text-foreground placeholder:text-muted-foreground w-full bg-transparent text-sm outline-none"
/> />
</div> </div>
); );
@@ -122,7 +122,7 @@ export function TextParticleView({
setIsEditing(true); setIsEditing(true);
}} }}
title="Edit" title="Edit"
className="absolute bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 -translate-x-1/2 z-30 flex items-center gap-1.5 rounded-full bg-black/40 px-3 py-1.5 text-xs text-white/70 backdrop-blur-sm transition-colors hover:bg-black/60 hover:text-white" className="bg-card/70 text-muted-foreground border-border hover:bg-accent hover:text-foreground absolute bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 z-30 flex -translate-x-1/2 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs backdrop-blur-sm transition-colors"
> >
<Pencil className="size-3.5" /> <Pencil className="size-3.5" />
Edit Edit
@@ -130,7 +130,7 @@ export function TextParticleView({
); );
const editedLabel = particle.properties.edited_at && ( const editedLabel = particle.properties.edited_at && (
<span className="text-xs text-white/40"> <span className="text-muted-foreground text-xs">
edited <RelativeTimestamp date={particle.properties.edited_at} /> edited <RelativeTimestamp date={particle.properties.edited_at} />
</span> </span>
); );
@@ -146,7 +146,7 @@ export function TextParticleView({
// Mode 1: bare URLs only — show link cards centered // Mode 1: bare URLs only — show link cards centered
if (linksOnly && !hasAttachments) { if (linksOnly && !hasAttachments) {
return ( return (
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]"> <div className="group relative flex h-full w-full items-center justify-center px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<LinkPreviews entries={linkPreviews} /> <LinkPreviews entries={linkPreviews} />
{editedLabel && ( {editedLabel && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2"> <div className="absolute bottom-6 left-1/2 -translate-x-1/2">
@@ -167,10 +167,10 @@ export function TextParticleView({
) { ) {
const style = getImmersiveTextStyle(content.length); const style = getImmersiveTextStyle(content.length);
return ( return (
<div className="group relative flex h-full w-full flex-col items-center justify-center gap-4 bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]"> <div className="group relative flex h-full w-full flex-col items-center justify-center gap-4 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<p <p
className={cn( className={cn(
'max-w-2xl break-words text-center leading-relaxed text-white select-text cursor-text', 'text-foreground max-w-2xl cursor-text select-text break-words text-center leading-relaxed',
style.size, style.size,
style.weight, style.weight,
)} )}
@@ -186,8 +186,8 @@ export function TextParticleView({
// Mode 3: card layout // Mode 3: card layout
return ( return (
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]"> <div className="group relative flex h-full w-full items-center justify-center px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<div className="scrollbar-card flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-4 overflow-y-auto overscroll-contain rounded bg-white/10 p-[var(--message-card-padding)] backdrop-blur-md"> <div className="scrollbar-card bg-card text-card-foreground border-border flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-4 overflow-y-auto overscroll-contain rounded border p-[var(--message-card-padding)]">
<MarkdownEditor <MarkdownEditor
key={content} key={content}
value={content} value={content}
@@ -54,7 +54,7 @@ export function TextReactionInput({
return ( return (
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="flex items-center gap-1 rounded-full bg-black/60 py-1 pl-3 pr-1 shadow-lg ring-1 ring-white/15 backdrop-blur-md" className="bg-popover/90 text-popover-foreground border-border flex items-center gap-1 rounded-full border py-1 pl-3 pr-1 shadow-lg backdrop-blur-md"
> >
<input <input
ref={inputRef} ref={inputRef}
@@ -74,12 +74,14 @@ export function TextReactionInput({
}} }}
placeholder="Quick reply…" placeholder="Quick reply…"
maxLength={MAX_LENGTH} maxLength={MAX_LENGTH}
className="w-24 bg-transparent text-sm text-white outline-none placeholder:text-white/40" className="text-foreground placeholder:text-muted-foreground w-24 bg-transparent text-sm outline-none"
/> />
<span <span
className={cn( className={cn(
'min-w-[1.5ch] text-right text-[10px] tabular-nums', 'min-w-[1.5ch] text-right text-[10px] tabular-nums',
remaining <= 8 ? 'text-amber-300/80' : 'text-white/30', remaining <= 8
? 'text-amber-600 dark:text-amber-400'
: 'text-muted-foreground',
)} )}
> >
{remaining} {remaining}
@@ -91,8 +93,8 @@ export function TextReactionInput({
className={cn( className={cn(
'ml-1 flex size-6 items-center justify-center rounded-full transition-colors', 'ml-1 flex size-6 items-center justify-center rounded-full transition-colors',
canSubmit canSubmit
? 'bg-white/20 text-white hover:bg-white/30' ? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'text-white/30', : 'text-muted-foreground',
)} )}
aria-label="Send reaction" aria-label="Send reaction"
> >
+57 -1
View File
@@ -12,11 +12,16 @@ import {
Volume2, Volume2,
ArrowLeft, ArrowLeft,
Camera, Camera,
Sun,
Moon,
Monitor,
Palette,
} from 'lucide-react'; } from 'lucide-react';
import { HumanAvatar } from '@/components/human-avatar'; import { HumanAvatar } from '@/components/human-avatar';
import { AvatarEditDialog } from '@/features/settings/avatar-edit-dialog'; import { AvatarEditDialog } from '@/features/settings/avatar-edit-dialog';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { WindowControls } from '@/components/window-controls'; import { WindowControls } from '@/components/window-controls';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Muted } from '@/components/ui/typography'; import { Muted } from '@/components/ui/typography';
@@ -24,6 +29,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { CopyableEmail } from '@/components/copyable-email'; import { CopyableEmail } from '@/components/copyable-email';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useSoundEffectsStore } from '@/stores/sound-effects-store'; import { useSoundEffectsStore } from '@/stores/sound-effects-store';
import { useThemeStore, type ThemeMode } from '@/stores/theme-store';
import { apiClient } from '@/api/client'; import { apiClient } from '@/api/client';
import { logError, toUserMessage } from '@/lib/errors'; import { logError, toUserMessage } from '@/lib/errors';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -80,6 +86,50 @@ function SettingsGroup({
); );
} }
const THEME_OPTIONS: {
value: ThemeMode;
label: string;
icon: React.ReactNode;
}[] = [
{ value: 'system', label: 'System', icon: <Monitor className="size-3.5" /> },
{ value: 'light', label: 'Light', icon: <Sun className="size-3.5" /> },
{ value: 'dark', label: 'Dark', icon: <Moon className="size-3.5" /> },
];
function ThemeSetting() {
const mode = useThemeStore((s) => s.mode);
const setMode = useThemeStore((s) => s.setMode);
return (
<div className="flex w-full items-center gap-3 px-4 py-3">
<span className="text-muted-foreground flex size-5 items-center justify-center">
<Palette className="size-4" />
</span>
<span className="min-w-0 flex-1 text-sm font-medium">Theme</span>
<ToggleGroup
type="single"
size="sm"
value={mode}
onValueChange={(value) => value && setMode(value as ThemeMode)}
className="border-border bg-muted/50 border p-0.5"
spacing={2}
>
{THEME_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={option.value}
aria-label={option.label}
className="data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm gap-1.5 rounded-md px-2.5 text-xs"
>
{option.icon}
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
);
}
export default function SettingsPage() { export default function SettingsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
@@ -150,7 +200,7 @@ export default function SettingsPage() {
initials={initials} initials={initials}
fallbackClassName="bg-primary/10 text-primary text-xl font-medium" fallbackClassName="bg-primary/10 text-primary text-xl font-medium"
/> />
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity group-hover:opacity-100" /> <span className="bg-scrim/40 absolute inset-0 flex items-center justify-center rounded-full opacity-0 transition-opacity group-hover:opacity-100" />
<span className="bg-primary text-primary-foreground ring-background absolute bottom-0 right-0 flex size-5 items-center justify-center rounded-full ring-2"> <span className="bg-primary text-primary-foreground ring-background absolute bottom-0 right-0 flex size-5 items-center justify-center rounded-full ring-2">
<Camera className="size-2.5" /> <Camera className="size-2.5" />
</span> </span>
@@ -165,6 +215,12 @@ export default function SettingsPage() {
<Separator /> <Separator />
<SettingsGroup title="Appearance">
<ThemeSetting />
</SettingsGroup>
<Separator className="mt-4" />
<SettingsGroup title="Notifications"> <SettingsGroup title="Notifications">
<div className="flex w-full items-center gap-3 px-4 py-3"> <div className="flex w-full items-center gap-3 px-4 py-3">
<span className="text-muted-foreground flex size-5 items-center justify-center"> <span className="text-muted-foreground flex size-5 items-center justify-center">
+14 -7
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { transferFiles } from '@/lib/data-transfer';
interface UseFileInputOptions { interface UseFileInputOptions {
onFilesSelected: (files: File[]) => void; onFilesSelected: (files: File[]) => void;
@@ -48,15 +49,20 @@ export function useFileInput({
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
// Capture phase so file pastes always become attachments — ProseMirror // Capture phase so file pastes always become attachments — the markdown
// would otherwise inline pasted images as ephemeral blob: URLs. Mixed // editor would otherwise inline a pasted image as an ephemeral blob: URL.
// clipboards (e.g. Excel/Word ship an image rendition alongside the text)
// must still paste as text, so only file-only pastes are intercepted.
const handlePaste = (e: ClipboardEvent) => { const handlePaste = (e: ClipboardEvent) => {
const data = e.clipboardData; const data = e.clipboardData;
if (!data) return; if (!data) return;
const files = Array.from(data.files);
if (files.length === 0 || data.types.includes('text/plain')) return; // Actual text means "paste as text", even when the clipboard also ships
// an image rendition (Excel/Word, or "copy image" from a page with alt
// text). Test the payload, not the advertised types: image pastes often
// list an *empty* text/plain entry that must not block the attachment.
if (data.getData('text/plain').trim().length > 0) return;
const files = transferFiles(data);
if (files.length === 0) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onFilesRef.current(files); onFilesRef.current(files);
@@ -105,7 +111,8 @@ export function useFileInput({
e.stopPropagation(); e.stopPropagation();
dragCountRef.current = 0; dragCountRef.current = 0;
setIsDragging(false); setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
const files = transferFiles(e.dataTransfer);
if (files.length > 0) { if (files.length > 0) {
onFilesRef.current(files); onFilesRef.current(files);
} }
+9
View File
@@ -4,6 +4,15 @@ export const MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
/** Maximum number of file attachments per particle. */ /** Maximum number of file attachments per particle. */
export const MAX_ATTACHMENTS = 10; export const MAX_ATTACHMENTS = 10;
/** Maximum recommended duration for a media (audio/video) recording, in seconds. */
export const RECORDING_MAX_DURATION_SECONDS = 60;
/**
* Minimum time the record key must be held to count as hold-to-record (vs. a
* quick tap that toggles), in milliseconds.
*/
export const HOLD_THRESHOLD_MS = 250;
export const SUPPORT_EMAIL = 'team@flowylabs.ai'; export const SUPPORT_EMAIL = 'team@flowylabs.ai';
export const PRIVACY_URL = 'https://flowylabs.ai/llink/privacy'; export const PRIVACY_URL = 'https://flowylabs.ai/llink/privacy';
+12
View File
@@ -0,0 +1,12 @@
/**
* Files carried by a paste or drag {@link DataTransfer}. Pasted/dragged images
* frequently surface only through `items` (`getAsFile`), with `files` left
* empty, so read `items` first and fall back to `files`.
*/
export function transferFiles(data: DataTransfer): File[] {
const fromItems = Array.from(data.items)
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
return fromItems.length > 0 ? fromItems : Array.from(data.files);
}
+1
View File
@@ -1,6 +1,7 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import App from './App'; import App from './App';
import { initSentryRenderer } from '@/lib/sentry'; import { initSentryRenderer } from '@/lib/sentry';
import '@/stores/theme-store';
import './styles/globals.css'; import './styles/globals.css';
initSentryRenderer(); initSentryRenderer();
+22
View File
@@ -0,0 +1,22 @@
import { create } from 'zustand';
const KEY = 'llink:onboarding-completed';
interface OnboardingState {
hasCompletedOnboarding: boolean;
markComplete: () => void;
/** Clears the flag so the wizard runs again (handy for QA). */
reset: () => void;
}
export const useOnboardingStore = create<OnboardingState>((set) => ({
hasCompletedOnboarding: localStorage.getItem(KEY) === 'true',
markComplete: () => {
localStorage.setItem(KEY, 'true');
set({ hasCompletedOnboarding: true });
},
reset: () => {
localStorage.removeItem(KEY);
set({ hasCompletedOnboarding: false });
},
}));
+63
View File
@@ -0,0 +1,63 @@
import { create } from 'zustand';
export type ThemeMode = 'light' | 'dark' | 'system';
const STORAGE_KEY = 'llink:theme';
const DEFAULT_MODE: ThemeMode = 'dark';
function loadMode(): ThemeMode {
try {
const v = localStorage.getItem(STORAGE_KEY);
if (v === 'light' || v === 'dark' || v === 'system') return v;
} catch {
// storage unavailable
}
return DEFAULT_MODE;
}
function systemPrefersDark(): boolean {
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true;
}
function resolveDark(mode: ThemeMode): boolean {
return mode === 'system' ? systemPrefersDark() : mode === 'dark';
}
function applyMode(mode: ThemeMode) {
document.documentElement.classList.toggle('dark', resolveDark(mode));
}
interface ThemeState {
mode: ThemeMode;
isDark: boolean;
setMode: (mode: ThemeMode) => void;
}
export const useThemeStore = create<ThemeState>((set, get) => {
const initialMode = loadMode();
applyMode(initialMode);
// Track the OS preference so "system" mode stays in sync live.
window
.matchMedia?.('(prefers-color-scheme: dark)')
.addEventListener('change', () => {
if (get().mode === 'system') {
applyMode('system');
set({ isDark: systemPrefersDark() });
}
});
return {
mode: initialMode,
isDark: resolveDark(initialMode),
setMode: (mode) => {
applyMode(mode);
set({ mode, isDark: resolveDark(mode) });
try {
localStorage.setItem(STORAGE_KEY, mode);
} catch {
// storage unavailable
}
},
};
});
+118 -65
View File
@@ -47,75 +47,92 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-scrim: var(--scrim);
} }
/* Warm Sand / Amber palette. Neutrals carry a low-chroma warm hue (~60-85°)
so surfaces read as paper/espresso rather than flat gray, with a burnt-amber
primary as the brand accent. */
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0); --background: oklch(0.985 0.006 83);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.255 0.012 64);
--card: oklch(1 0 0); --card: oklch(0.998 0.003 90);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.255 0.012 64);
--popover: oklch(1 0 0); --popover: oklch(0.998 0.003 90);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.255 0.012 64);
--primary: oklch(0.205 0 0); --primary: oklch(0.62 0.128 62);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.99 0.006 90);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.945 0.014 78);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.32 0.014 60);
--muted: oklch(0.97 0 0); --muted: oklch(0.945 0.012 78);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.535 0.02 62);
--accent: oklch(0.97 0 0); --accent: oklch(0.92 0.03 70);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.3 0.035 55);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.9 0.014 75);
--input: oklch(0.922 0 0); --input: oklch(0.9 0.014 75);
--ring: oklch(0.708 0 0); --ring: oklch(0.62 0.128 62);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.66 0.14 60);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.7 0.12 95);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.58 0.09 45);
--chart-4: oklch(0.828 0.189 84.429); --chart-4: oklch(0.75 0.13 80);
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.6 0.11 30);
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.965 0.01 80);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.255 0.012 64);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.62 0.128 62);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.99 0.006 90);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.92 0.03 70);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.3 0.035 55);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.9 0.014 75);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.62 0.128 62);
--scrollbar-thumb: oklch(0.45 0.03 60 / 25%);
--scrollbar-thumb-hover: oklch(0.45 0.03 60 / 40%);
--scrollbar-thumb-strong: oklch(0.45 0.03 60 / 35%);
--scrollbar-thumb-strong-hover: oklch(0.45 0.03 60 / 55%);
/* Modal/overlay backdrop. A dim scrim behind dialogs reads the same in both
themes (matching shadcn's default overlay), so it is intentionally
theme-independent. Use with an alpha modifier, e.g. bg-scrim/60. */
--scrim: oklch(0 0 0);
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.19 0.012 58);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.955 0.008 85);
--card: oklch(0.205 0 0); --card: oklch(0.232 0.014 56);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.955 0.008 85);
--popover: oklch(0.205 0 0); --popover: oklch(0.232 0.014 56);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.955 0.008 85);
--primary: oklch(0.922 0 0); --primary: oklch(0.78 0.13 78);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.24 0.03 60);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.285 0.014 54);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.955 0.008 85);
--muted: oklch(0.269 0 0); --muted: oklch(0.285 0.014 54);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.72 0.018 72);
--accent: oklch(0.269 0 0); --accent: oklch(0.33 0.032 56);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.955 0.008 85);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(0.92 0.03 85 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(0.92 0.03 85 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.78 0.13 78);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.78 0.13 78);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.7 0.12 50);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.72 0.11 95);
--chart-4: oklch(0.627 0.265 303.9); --chart-4: oklch(0.65 0.13 35);
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.6 0.1 60);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.165 0.012 58);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.955 0.008 85);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.78 0.13 78);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.24 0.03 60);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.33 0.032 56);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.955 0.008 85);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(0.92 0.03 85 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.78 0.13 78);
--scrollbar-thumb: oklch(0.92 0.03 85 / 18%);
--scrollbar-thumb-hover: oklch(0.92 0.03 85 / 32%);
--scrollbar-thumb-strong: oklch(0.92 0.03 85 / 30%);
--scrollbar-thumb-strong-hover: oklch(0.92 0.03 85 / 50%);
} }
@layer base { @layer base {
@@ -130,7 +147,7 @@
/* Scrollbar styling */ /* Scrollbar styling */
* { * {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: oklch(1 0 0 / 20%) transparent; scrollbar-color: var(--scrollbar-thumb) transparent;
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
@@ -143,12 +160,12 @@
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: oklch(1 0 0 / 20%); background: var(--scrollbar-thumb);
border-radius: 3px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: oklch(1 0 0 / 35%); background: var(--scrollbar-thumb-hover);
} }
/* Message cards: more prominent scrollbar than the subtle global default. /* Message cards: more prominent scrollbar than the subtle global default.
@@ -160,13 +177,13 @@
.scrollbar-card::-webkit-scrollbar-thumb, .scrollbar-card::-webkit-scrollbar-thumb,
.scrollbar-card ::-webkit-scrollbar-thumb { .scrollbar-card ::-webkit-scrollbar-thumb {
background: oklch(1 0 0 / 30%); background: var(--scrollbar-thumb-strong);
border-radius: 9999px; border-radius: 9999px;
} }
.scrollbar-card::-webkit-scrollbar-thumb:hover, .scrollbar-card::-webkit-scrollbar-thumb:hover,
.scrollbar-card ::-webkit-scrollbar-thumb:hover { .scrollbar-card ::-webkit-scrollbar-thumb:hover {
background: oklch(1 0 0 / 50%); background: var(--scrollbar-thumb-strong-hover);
} }
/* Message layout: the compose editor and the posted card derive their widths /* Message layout: the compose editor and the posted card derive their widths
@@ -186,3 +203,39 @@
.no-drag { .no-drag {
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
/* Recording time-limit progress bar: a single CSS animation fills the bar
leftright over the recording duration (duration set inline), avoiding
per-frame React renders. */
@keyframes record-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
.record-progress {
transform: scaleX(0);
animation-name: record-progress;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
/* Pulsing red glow around the recording overlay during the final warning window. */
@keyframes record-warning-glow {
0%,
100% {
box-shadow:
inset 0 0 0 2px var(--destructive),
inset 0 0 24px 0 oklch(0.6 0.24 27 / 0.25);
}
50% {
box-shadow:
inset 0 0 0 3px var(--destructive),
inset 0 0 60px 0 oklch(0.6 0.24 27 / 0.5);
}
}
.record-warning-glow {
animation: record-warning-glow 1s ease-in-out infinite;
}
+17 -1
View File
@@ -1,10 +1,26 @@
<!doctype html> <!doctype html>
<html class="dark"> <html>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/png" href="/icon.png" /> <link rel="icon" type="image/png" href="/icon.png" />
<title>llink</title> <title>llink</title>
<script>
// Apply the stored theme before first paint to avoid a flash. Mirrors
// the logic in stores/theme-store.ts; defaults to dark.
try {
var m = localStorage.getItem('llink:theme');
var dark =
m === 'light'
? false
: m === 'system'
? matchMedia('(prefers-color-scheme: dark)').matches
: true;
if (dark) document.documentElement.classList.add('dark');
} catch (e) {
document.documentElement.classList.add('dark');
}
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1
View File
@@ -1,6 +1,7 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import App from '@/App'; import App from '@/App';
import { initSentryRenderer } from '@/lib/sentry'; import { initSentryRenderer } from '@/lib/sentry';
import '@/stores/theme-store';
import '@/styles/globals.css'; import '@/styles/globals.css';
initSentryRenderer(); initSentryRenderer();
+7
View File
@@ -55,6 +55,13 @@ const config: ExpoConfig = {
'Flowy uses your microphone to record voice messages.', 'Flowy uses your microphone to record voice messages.',
}, },
], ],
[
'expo-image-picker',
{
photosPermission:
'Flowy uses your photo library to set your profile picture.',
},
],
[ [
'expo-notifications', 'expo-notifications',
{ {
+1
View File
@@ -31,6 +31,7 @@
"expo-device": "~8.0.10", "expo-device": "~8.0.10",
"expo-file-system": "~19.0.16", "expo-file-system": "~19.0.16",
"expo-haptics": "~15.0.7", "expo-haptics": "~15.0.7",
"expo-image-picker": "~17.0.8",
"expo-notifications": "~0.32.17", "expo-notifications": "~0.32.17",
"expo-secure-store": "~15.0.8", "expo-secure-store": "~15.0.8",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
+40
View File
@@ -1,3 +1,4 @@
import { FileSystemUploadType, uploadAsync } from 'expo-file-system/legacy';
import { appConfig } from '@/config/env'; import { appConfig } from '@/config/env';
import { ApiError } from '@/lib/errors'; import { ApiError } from '@/lib/errors';
import type { z } from 'zod'; import type { z } from 'zod';
@@ -136,6 +137,45 @@ class ApiClient {
await this.requestVoid('PATCH', '/humans/me/settings', data); await this.requestVoid('PATCH', '/humans/me/settings', data);
} }
// --- Avatar ---
/**
* Upload a new profile picture. The endpoint takes the raw image bytes as
* the request body (not multipart), so we stream the file directly via
* expo-file-system rather than the JSON `fetch` helper.
*/
async uploadAvatar(fileUri: string, mimeType: string): Promise<void> {
const headers: Record<string, string> = { 'Content-Type': mimeType };
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
const result = await uploadAsync(
`${this.baseUrl}/humans/me/avatar`,
fileUri,
{
httpMethod: 'PUT',
uploadType: FileSystemUploadType.BINARY_CONTENT,
headers,
},
);
if (result.status === 401) {
throw new ApiError(401, 'Unauthorized');
}
if (result.status < 200 || result.status >= 300) {
throw new ApiError(result.status, result.body || 'Avatar upload failed');
}
}
async deleteAvatar(): Promise<void> {
await this.requestVoid('DELETE', '/humans/me/avatar');
}
async getAvatarDownloadUrl(objectId: string): Promise<string> {
const response = await this.fetch('GET', `/humans/avatar/${objectId}`);
const data = await response.json();
return data.url;
}
// --- Push notification tokens --- // --- Push notification tokens ---
async registerPushToken(data: { async registerPushToken(data: {
+16 -7
View File
@@ -6,6 +6,7 @@ export const HumanSchema = z.object({
email: z.string().email(), email: z.string().email(),
email_prefix: z.string(), email_prefix: z.string(),
email_notifications_enabled: z.boolean(), email_notifications_enabled: z.boolean(),
avatar_object_id: z.string().nullable().optional(),
}); });
export type Human = z.infer<typeof HumanSchema>; export type Human = z.infer<typeof HumanSchema>;
@@ -145,14 +146,21 @@ export const TextPropertiesSchema = z.object({
}); });
export type TextProperties = z.infer<typeof TextPropertiesSchema>; export type TextProperties = z.infer<typeof TextPropertiesSchema>;
export const QuestPropertiesSchema = z.object({ export const ChecklistItemSchema = z.object({
text: z.string(),
done: z.boolean(),
});
export type ChecklistItem = z.infer<typeof ChecklistItemSchema>;
export const TaskPropertiesSchema = z.object({
title: z.string(), title: z.string(),
description: z.string(), notes: z.string().optional(),
status: z.string().optional(), checklist: z.array(ChecklistItemSchema).optional(),
// humanId // humanId
assigned_to: z.string().optional(), assigned_to: z.string().optional(),
done: z.boolean(),
}); });
export type QuestProperties = z.infer<typeof QuestPropertiesSchema>; export type TaskProperties = z.infer<typeof TaskPropertiesSchema>;
export const PaperPropertiesSchema = z.object({ export const PaperPropertiesSchema = z.object({
title: z.string(), title: z.string(),
@@ -193,7 +201,7 @@ export interface ParticlePropertiesMap {
media: MediaProperties; media: MediaProperties;
file: FileProperties; file: FileProperties;
text: TextProperties; text: TextProperties;
quest: QuestProperties; task: TaskProperties;
paper: PaperProperties; paper: PaperProperties;
} }
@@ -247,8 +255,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [
...TombstoneFields, ...TombstoneFields,
}), }),
ParticleBaseSchema.extend({ ParticleBaseSchema.extend({
type: z.literal('quest'), type: z.literal('task'),
properties: QuestPropertiesSchema, properties: TaskPropertiesSchema,
reactions: ReactionsSchema,
...TombstoneFields, ...TombstoneFields,
}), }),
ParticleBaseSchema.extend({ ParticleBaseSchema.extend({
+27 -10
View File
@@ -1,9 +1,10 @@
import { Text, View } from 'react-native'; import { Image, Text, View } from 'react-native';
import type { Human } from '@/api/types'; import type { Human } from '@/api/types';
import { useAvatarUrl } from '@/hooks/use-avatar-url';
import { resolveHumanDisplay } from '@/lib/humans'; import { resolveHumanDisplay } from '@/lib/humans';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type Size = 'xs' | 'sm' | 'md'; type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
interface AvatarProps { interface AvatarProps {
humanId: string | null | undefined; humanId: string | null | undefined;
@@ -13,6 +14,8 @@ interface AvatarProps {
online?: boolean; online?: boolean;
/** Background ring used to separate stacked avatars from the chrome. */ /** Background ring used to separate stacked avatars from the chrome. */
stackBg?: string; stackBg?: string;
/** Initials shown when the human can't be resolved (e.g. a group stream). */
fallbackInitials?: string;
className?: string; className?: string;
} }
@@ -20,12 +23,15 @@ const sizeMap: Record<Size, { box: string; text: string; ring: number }> = {
xs: { box: 'h-6 w-6', text: 'text-[9px]', ring: 1.5 }, xs: { box: 'h-6 w-6', text: 'text-[9px]', ring: 1.5 },
sm: { box: 'h-9 w-9', text: 'text-xs', ring: 2 }, sm: { box: 'h-9 w-9', text: 'text-xs', ring: 2 },
md: { box: 'h-10 w-10', text: 'text-sm', ring: 2 }, md: { box: 'h-10 w-10', text: 'text-sm', ring: 2 },
lg: { box: 'h-16 w-16', text: 'text-xl', ring: 2.5 },
xl: { box: 'h-24 w-24', text: 'text-3xl', ring: 3 },
}; };
/** /**
* Initials avatar with optional online ring (green) and an optional outer * Human avatar: renders the profile picture when one is set (resolved to a
* stack ring used to visually separate overlapping avatars on a busy chrome. * signed URL via React Query), otherwise initials. Supports an optional online
* Matches desktop's avatar + presence pattern (`ring-2 ring-green-500`). * ring (green) and an outer stack separator ring used to keep overlapping
* avatars distinct on busy chrome. Matches desktop's avatar + presence pattern.
*/ */
export function Avatar({ export function Avatar({
humanId, humanId,
@@ -33,15 +39,22 @@ export function Avatar({
size = 'sm', size = 'sm',
online = false, online = false,
stackBg, stackBg,
fallbackInitials,
className, className,
}: AvatarProps) { }: AvatarProps) {
const { initials } = resolveHumanDisplay(humanId, humans); const display = resolveHumanDisplay(humanId, humans);
const human = humanId ? humans?.find((h) => h.id === humanId) : undefined;
const avatarUrl = useAvatarUrl(human?.avatar_object_id);
const dims = sizeMap[size]; const dims = sizeMap[size];
const initials =
display.exists || fallbackInitials === undefined
? display.initials
: fallbackInitials;
return ( return (
<View <View
className={cn( className={cn(
'bg-black/15 items-center justify-center rounded-full', 'bg-black/15 items-center justify-center overflow-hidden rounded-full',
dims.box, dims.box,
className, className,
)} )}
@@ -52,9 +65,13 @@ export function Avatar({
borderColor: online ? '#22c55e' : (stackBg ?? 'transparent'), borderColor: online ? '#22c55e' : (stackBg ?? 'transparent'),
}} }}
> >
<Text className={cn('text-white font-semibold', dims.text)}> {avatarUrl ? (
{initials} <Image source={{ uri: avatarUrl }} className="h-full w-full" />
</Text> ) : (
<Text className={cn('text-white font-semibold', dims.text)}>
{initials}
</Text>
)}
</View> </View>
); );
} }
+165
View File
@@ -0,0 +1,165 @@
import { Fragment, type ReactNode } from 'react';
import { Platform, type ViewStyle } from 'react-native';
import { Renderer, useMarkdown, type MarkedStyles } from 'react-native-marked';
// Shared markdown rendering for text and paper particles. Mirrors the desktop
// Crepe palette (markdown-editor.css `--crepe-*`) so a message reads the same
// on both surfaces: white-on-transparent text, a blue accent, pink inline
// code, and a near-opaque dark surface behind code blocks and tables.
//
// Known gap vs desktop: fenced code blocks aren't syntax-highlighted (Crepe
// uses CodeMirror; react-native-marked only exposes the language tag). They
// render as plain monospace on the dark surface, which is acceptable for v1.
const TEXT_COLOR = 'rgba(255,255,255,0.92)';
const ACCENT = '#60a5fa';
const SURFACE = 'rgba(24,24,28,0.96)';
const OUTLINE = 'rgba(255,255,255,0.2)';
const MONO = Platform.OS === 'ios' ? 'Menlo' : 'monospace';
// react-native-marked doesn't render GFM task-list checkboxes (marked strips
// the `[ ]`/`[x]` into token flags the parser ignores), so a write/read drift
// shows up as bullets with no box. Swap the marker for a checkbox glyph before
// parsing — read-only, matching desktop's bullet-free checkboxes.
const TASK_ITEM_RE = /^(\s*)[-*+] \[([ xX])\] /gm;
function withTaskCheckboxes(markdown: string): string {
return markdown.replace(
TASK_ITEM_RE,
(_match, indent: string, mark: string) =>
`${indent}${mark === ' ' ? '☐' : '☑'} `,
);
}
const MARKDOWN_THEME = {
colors: {
text: TEXT_COLOR,
link: ACCENT,
code: SURFACE,
border: OUTLINE,
},
};
const MARKDOWN_STYLES: MarkedStyles = {
text: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
li: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
strong: { fontWeight: '700' },
em: { fontStyle: 'italic' },
strikethrough: {
textDecorationLine: 'line-through',
color: 'rgba(255,255,255,0.6)',
},
// fontStyle "normal" cancels react-native-marked's italic-by-default for
// links and inline code (desktop renders neither italic).
link: { color: ACCENT, fontStyle: 'normal' },
// borderBottomWidth 0 removes the library's default heading underline rule,
// which desktop's headings don't have.
h1: {
color: '#ffffff',
fontSize: 28,
lineHeight: 34,
fontWeight: '700',
marginTop: 8,
marginBottom: 8,
borderBottomWidth: 0,
},
h2: {
color: '#ffffff',
fontSize: 24,
lineHeight: 30,
fontWeight: '700',
marginTop: 8,
marginBottom: 6,
borderBottomWidth: 0,
},
h3: {
color: '#ffffff',
fontSize: 20,
lineHeight: 26,
fontWeight: '600',
marginTop: 6,
marginBottom: 4,
},
h4: {
color: '#ffffff',
fontSize: 18,
lineHeight: 24,
fontWeight: '600',
marginTop: 6,
marginBottom: 4,
},
h5: {
color: '#ffffff',
fontSize: 16,
lineHeight: 22,
fontWeight: '600',
marginTop: 4,
marginBottom: 2,
},
h6: {
color: 'rgba(255,255,255,0.7)',
fontSize: 15,
lineHeight: 20,
fontWeight: '600',
marginTop: 4,
marginBottom: 2,
},
codespan: {
color: '#fca5a5',
fontFamily: MONO,
fontStyle: 'normal',
backgroundColor: 'rgba(255,255,255,0.1)',
},
code: {
backgroundColor: SURFACE,
borderColor: OUTLINE,
borderWidth: 1,
borderRadius: 8,
padding: 12,
marginVertical: 6,
},
blockquote: {
borderLeftWidth: 3,
borderLeftColor: OUTLINE,
paddingLeft: 12,
marginVertical: 6,
opacity: 0.85,
},
// hr is left to the library default, which already draws a 1px rule in the
// themed border color (OUTLINE).
table: { borderWidth: 1, borderColor: OUTLINE, marginVertical: 6 },
tableRow: { borderColor: OUTLINE },
tableCell: { borderColor: OUTLINE, padding: 8 },
};
// react-native-marked feeds fenced code blocks the `em` (italic, proportional)
// text style, so out of the box code renders italic in the body font. Override
// `code` to apply a monospace, non-italic style instead — matching desktop's
// code blocks.
const CODE_TEXT_STYLE = {
color: TEXT_COLOR,
fontFamily: MONO,
fontSize: 15,
lineHeight: 22,
};
class MarkdownRenderer extends Renderer {
code(text: string, language?: string, containerStyle?: ViewStyle): ReactNode {
return super.code(text, language, containerStyle, CODE_TEXT_STYLE);
}
}
const MARKDOWN_RENDERER = new MarkdownRenderer();
/**
* Renders GFM markdown using the shared Flowy palette. `useMarkdown` returns an
* array of block nodes; we splat them into a Fragment so they nest cleanly
* inside a parent ScrollView (vs. the library's own FlatList-based component).
*/
export function MarkdownBody({ content }: { content: string }) {
const nodes = useMarkdown(withTaskCheckboxes(content), {
renderer: MARKDOWN_RENDERER,
theme: MARKDOWN_THEME,
styles: MARKDOWN_STYLES,
});
return <Fragment>{nodes}</Fragment>;
}
+100 -36
View File
@@ -1,6 +1,11 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Pressable, Text, View } from 'react-native'; import { Pressable, Text, View } from 'react-native';
import { Mic, Type as TypeIcon, Video as VideoIcon } from 'lucide-react-native'; import {
ListTodo,
Mic,
Type as TypeIcon,
Video as VideoIcon,
} from 'lucide-react-native';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { useCameraPermissions, useMicrophonePermissions } from 'expo-camera'; import { useCameraPermissions, useMicrophonePermissions } from 'expo-camera';
import { toast } from 'sonner-native'; import { toast } from 'sonner-native';
@@ -8,13 +13,18 @@ import { cn } from '@/lib/utils';
import { useEvent } from '@/hooks/use-event'; import { useEvent } from '@/hooks/use-event';
import { usePlaybackPauseStore } from '@/stores/playback-pause-store'; import { usePlaybackPauseStore } from '@/stores/playback-pause-store';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { createTextParticle, uploadMediaParticle } from '@/lib/upload'; import {
createTaskParticle,
createTextParticle,
uploadMediaParticle,
} from '@/lib/upload';
import type { ParticlePath } from '@/lib/particle-path'; import type { ParticlePath } from '@/lib/particle-path';
import { import {
useStreamComposingBroadcastOptional, useStreamComposingBroadcastOptional,
type ComposingMode, type ComposingMode,
} from '@/features/stream-view/stream-presence-context'; } from '@/features/stream-view/stream-presence-context';
import { TextComposeModal } from './TextComposeModal'; import { TextComposeModal } from './TextComposeModal';
import { TaskComposeSheet } from './TaskComposeSheet';
import { VideoRecordingOverlay } from './VideoRecordingOverlay'; import { VideoRecordingOverlay } from './VideoRecordingOverlay';
import { AudioRecordingOverlay } from './AudioRecordingOverlay'; import { AudioRecordingOverlay } from './AudioRecordingOverlay';
import { ReviewSheet } from './ReviewSheet'; import { ReviewSheet } from './ReviewSheet';
@@ -48,6 +58,12 @@ interface ComposeDockProps {
networkId: string; networkId: string;
targetPath: ParticlePath; targetPath: ParticlePath;
silentPresence?: boolean; silentPresence?: boolean;
/**
* Whether to show the task compose button. Disabled in the new-stream flow,
* where a stream's first particle must be text or media (a task can't open a
* stream it's added once the stream exists).
*/
allowTask?: boolean;
submitMedia?: (params: SubmitMediaParams) => Promise<void>; submitMedia?: (params: SubmitMediaParams) => Promise<void>;
submitText?: (content: string) => Promise<void>; submitText?: (content: string) => Promise<void>;
/** /**
@@ -63,6 +79,7 @@ export function ComposeDock({
networkId, networkId,
targetPath, targetPath,
silentPresence = false, silentPresence = false,
allowTask = true,
submitMedia, submitMedia,
submitText: submitTextOverride, submitText: submitTextOverride,
onParticleCreated, onParticleCreated,
@@ -72,6 +89,7 @@ export function ComposeDock({
const [mode, setMode] = useState<RecordingMode>('video'); const [mode, setMode] = useState<RecordingMode>('video');
const [ui, setUi] = useState<ComposeUiState>({ kind: 'idle' }); const [ui, setUi] = useState<ComposeUiState>({ kind: 'idle' });
const [textOpen, setTextOpen] = useState(false); const [textOpen, setTextOpen] = useState(false);
const [taskOpen, setTaskOpen] = useState(false);
const [camPerm, requestCamPerm] = useCameraPermissions(); const [camPerm, requestCamPerm] = useCameraPermissions();
const [micPerm, requestMicPerm] = useMicrophonePermissions(); const [micPerm, requestMicPerm] = useMicrophonePermissions();
@@ -79,13 +97,13 @@ export function ComposeDock({
// Tell StreamView to fully unmount its expo-video player while we record. // Tell StreamView to fully unmount its expo-video player while we record.
// That player otherwise holds the iOS AVAudioSession and crashes the camera. // That player otherwise holds the iOS AVAudioSession and crashes the camera.
const setComposing = usePlaybackPauseStore((s) => s.setComposing); const setComposing = usePlaybackPauseStore((s) => s.setComposing);
const isComposing = ui.kind !== 'idle' || textOpen; const isComposing = ui.kind !== 'idle' || textOpen || taskOpen;
useEffect(() => { useEffect(() => {
setComposing(isComposing); setComposing(isComposing);
return () => setComposing(false); return () => setComposing(false);
}, [isComposing, setComposing]); }, [isComposing, setComposing]);
useComposingBroadcast({ ui, textOpen, silent: silentPresence }); useComposingBroadcast({ ui, textOpen, taskOpen, silent: silentPresence });
const ensurePermissions = useCallback( const ensurePermissions = useCallback(
async (forVideo: boolean): Promise<boolean> => { async (forVideo: boolean): Promise<boolean> => {
@@ -187,6 +205,20 @@ export function ComposeDock({
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}); });
const submitTask = useEvent(
async ({ title, notes }: { title: string; notes?: string }) => {
if (!userId) throw new Error('Not signed in.');
const particleId = await createTaskParticle({
targetPath,
title,
notes,
createdByHumanId: userId,
});
onParticleCreated?.(particleId);
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
},
);
const dockHidden = const dockHidden =
ui.kind === 'review' || ui.kind === 'uploading' || ui.kind === 'recording'; ui.kind === 'review' || ui.kind === 'uploading' || ui.kind === 'recording';
@@ -196,27 +228,31 @@ export function ComposeDock({
<View pointerEvents="box-none" className="absolute inset-x-0 bottom-0"> <View pointerEvents="box-none" className="absolute inset-x-0 bottom-0">
<View <View
pointerEvents="box-none" pointerEvents="box-none"
className="flex-row items-center justify-between px-8 pb-10" className="flex-row items-center px-8 pb-10"
> >
<Pressable {/* Left and right clusters flex equally so the record button stays
onPress={() => centered regardless of how many side controls are present. */}
setMode((m) => (m === 'video' ? 'audio' : 'video')) <View className="flex-1 flex-row items-center">
} <Pressable
disabled={ui.kind !== 'idle'} onPress={() =>
accessibilityLabel={`Switch to ${ setMode((m) => (m === 'video' ? 'audio' : 'video'))
mode === 'video' ? 'audio' : 'video' }
} mode`} disabled={ui.kind !== 'idle'}
className={cn( accessibilityLabel={`Switch to ${
'h-11 w-11 items-center justify-center rounded-full bg-white/15', mode === 'video' ? 'audio' : 'video'
ui.kind !== 'idle' && 'opacity-40', } mode`}
)} className={cn(
> 'h-11 w-11 items-center justify-center rounded-full bg-white/15',
{mode === 'video' ? ( ui.kind !== 'idle' && 'opacity-40',
<VideoIcon color="white" size={20} strokeWidth={1.6} /> )}
) : ( >
<Mic color="white" size={20} strokeWidth={1.6} /> {mode === 'video' ? (
)} <VideoIcon color="white" size={20} strokeWidth={1.6} />
</Pressable> ) : (
<Mic color="white" size={20} strokeWidth={1.6} />
)}
</Pressable>
</View>
<View className="items-center"> <View className="items-center">
<Pressable <Pressable
@@ -230,17 +266,33 @@ export function ComposeDock({
<Text className="text-white/60 mt-2 text-xs">Tap to record</Text> <Text className="text-white/60 mt-2 text-xs">Tap to record</Text>
</View> </View>
<Pressable <View className="flex-1 flex-row items-center justify-end gap-3">
onPress={() => setTextOpen(true)} {allowTask ? (
disabled={ui.kind !== 'idle'} <Pressable
accessibilityLabel="Compose text" onPress={() => setTaskOpen(true)}
className={cn( disabled={ui.kind !== 'idle'}
'h-11 w-11 items-center justify-center rounded-full bg-white/15', accessibilityLabel="Create task"
ui.kind !== 'idle' && 'opacity-40', className={cn(
)} 'h-11 w-11 items-center justify-center rounded-full bg-white/15',
> ui.kind !== 'idle' && 'opacity-40',
<TypeIcon color="white" size={20} strokeWidth={1.6} /> )}
</Pressable> >
<ListTodo color="white" size={20} strokeWidth={1.6} />
</Pressable>
) : null}
<Pressable
onPress={() => setTextOpen(true)}
disabled={ui.kind !== 'idle'}
accessibilityLabel="Compose text"
className={cn(
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
ui.kind !== 'idle' && 'opacity-40',
)}
>
<TypeIcon color="white" size={20} strokeWidth={1.6} />
</Pressable>
</View>
</View> </View>
</View> </View>
) : null} ) : null}
@@ -277,6 +329,12 @@ export function ComposeDock({
onClose={() => setTextOpen(false)} onClose={() => setTextOpen(false)}
onSubmit={submitText} onSubmit={submitText}
/> />
<TaskComposeSheet
open={taskOpen}
onClose={() => setTaskOpen(false)}
onSubmit={submitTask}
/>
</> </>
); );
} }
@@ -284,17 +342,23 @@ export function ComposeDock({
function useComposingBroadcast({ function useComposingBroadcast({
ui, ui,
textOpen, textOpen,
taskOpen,
silent, silent,
}: { }: {
ui: ComposeUiState; ui: ComposeUiState;
textOpen: boolean; textOpen: boolean;
taskOpen: boolean;
silent: boolean; silent: boolean;
}) { }) {
// null when the dock is rendered outside a stream (no presence provider). // null when the dock is rendered outside a stream (no presence provider).
const broadcast = useStreamComposingBroadcastOptional(); const broadcast = useStreamComposingBroadcastOptional();
const mode: ComposingMode | null = const mode: ComposingMode | null =
ui.kind === 'recording' ? 'recording' : textOpen ? 'typing' : null; ui.kind === 'recording'
? 'recording'
: textOpen || taskOpen
? 'typing'
: null;
useEffect(() => { useEffect(() => {
if (silent || !broadcast) return; if (silent || !broadcast) return;
@@ -0,0 +1,102 @@
import { useState } from 'react';
import { Pressable, Text, TextInput, View } from 'react-native';
import { toast } from 'sonner-native';
import { BottomSheet } from '@/components/BottomSheet';
import { toUserMessage } from '@/lib/errors';
import { cn } from '@/lib/utils';
interface TaskComposeSheetProps {
open: boolean;
onClose: () => void;
/** Create the task. Must throw on failure so the sheet keeps the draft. */
onSubmit: (input: { title: string; notes?: string }) => Promise<void>;
}
/**
* Quick task creator. Mirrors desktop's task-compose-step but pared to the
* essentials title (required) plus optional notes. Checklist and assignee
* are added inline in the task card once it exists.
*/
export function TaskComposeSheet({
open,
onClose,
onSubmit,
}: TaskComposeSheetProps) {
const [title, setTitle] = useState('');
const [notes, setNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const [prevOpen, setPrevOpen] = useState(open);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) {
setTitle('');
setNotes('');
setSubmitting(false);
}
}
const trimmedTitle = title.trim();
const canSend = trimmedTitle.length > 0 && !submitting;
const handleSubmit = async () => {
if (!canSend) return;
setSubmitting(true);
try {
await onSubmit({
title: trimmedTitle,
notes: notes.trim() || undefined,
});
onClose();
} catch (err) {
toast.error(toUserMessage(err));
setSubmitting(false);
}
};
return (
<BottomSheet open={open} onClose={onClose} avoidKeyboard maxHeight="70%">
<View className="px-5 pb-4">
<Text className="text-white text-lg font-semibold">New task</Text>
<TextInput
value={title}
onChangeText={setTitle}
placeholder="Task title"
placeholderTextColor="rgba(255,255,255,0.4)"
autoFocus
editable={!submitting}
className="text-white text-base bg-white/10 rounded-xl px-4 py-3 mt-4"
/>
<TextInput
value={notes}
onChangeText={setNotes}
placeholder="Notes (optional)"
placeholderTextColor="rgba(255,255,255,0.4)"
multiline
editable={!submitting}
className="text-white text-base bg-white/10 rounded-xl px-4 py-3 mt-3 min-h-20"
/>
<Pressable
onPress={handleSubmit}
disabled={!canSend}
className={cn(
'mt-4 rounded-xl py-3 items-center',
canSend ? 'bg-white' : 'bg-white/20',
)}
>
<Text
className={cn(
'text-base font-semibold',
canSend ? 'text-black' : 'text-white/40',
)}
>
{submitting ? 'Creating…' : 'Create task'}
</Text>
</Pressable>
</View>
</BottomSheet>
);
}
@@ -1,6 +1,7 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { Alert, Dimensions, Pressable, Text, View } from 'react-native'; import { Alert, Dimensions, Pressable, Text, View } from 'react-native';
import type { Human } from '@/api/types'; import type { Human } from '@/api/types';
import { Avatar } from '@/components/Avatar';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { import {
@@ -215,7 +216,6 @@ function Tile({
const identity = tile.participant.identity; const identity = tile.participant.identity;
const display = resolveHumanDisplay(identity, humans); const display = resolveHumanDisplay(identity, humans);
const name = tile.participant.name || display.displayName; const name = tile.participant.name || display.displayName;
const initials = display.initials;
const isSpeaking = tile.participant.isSpeaking; const isSpeaking = tile.participant.isSpeaking;
const muted = const muted =
tile.participant.getTrackPublication(Track.Source.Microphone)?.isMuted ?? tile.participant.getTrackPublication(Track.Source.Microphone)?.isMuted ??
@@ -234,9 +234,7 @@ function Tile({
<VideoTrack trackRef={tile} style={{ flex: 1 }} objectFit="cover" /> <VideoTrack trackRef={tile} style={{ flex: 1 }} objectFit="cover" />
) : ( ) : (
<View className="flex-1 items-center justify-center"> <View className="flex-1 items-center justify-center">
<View className="h-16 w-16 items-center justify-center rounded-full bg-neutral-700"> <Avatar humanId={identity} humans={humans} size="lg" />
<Text className="text-white text-xl font-semibold">{initials}</Text>
</View>
</View> </View>
)} )}
<View className="absolute left-2 bottom-2 flex-row items-center gap-1 rounded-full bg-black/60 px-2 py-1"> <View className="absolute left-2 bottom-2 flex-row items-center gap-1 rounded-full bg-black/60 px-2 py-1">
@@ -0,0 +1,145 @@
import { useState } from 'react';
import { Pressable, Text, TextInput, View } from 'react-native';
import { toast } from 'sonner-native';
import { BottomSheet } from '@/components/BottomSheet';
import { useAddMembers } from '@/hooks/use-member-management';
import { toUserMessage } from '@/lib/errors';
import { cn } from '@/lib/utils';
interface AddMembersSheetProps {
open: boolean;
onClose: () => void;
networkId: string;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Invite people to a network by email. Mirrors desktop's add-members-dialog
* accepts one email at a time (comma/space/enter to commit), shows chips, and
* submits the batch via `addMembers`.
*/
export function AddMembersSheet({
open,
onClose,
networkId,
}: AddMembersSheetProps) {
const [draft, setDraft] = useState('');
const [emails, setEmails] = useState<string[]>([]);
const addMembers = useAddMembers(networkId);
const [prevOpen, setPrevOpen] = useState(open);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) {
setDraft('');
setEmails([]);
}
}
const commitDraft = (): string[] => {
const candidate = draft.trim().toLowerCase().replace(/,$/, '');
if (!candidate) return emails;
if (!EMAIL_RE.test(candidate)) {
toast.error('Enter a valid email address');
return emails;
}
if (emails.includes(candidate)) {
setDraft('');
return emails;
}
const next = [...emails, candidate];
setEmails(next);
setDraft('');
return next;
};
const removeEmail = (email: string) =>
setEmails((prev) => prev.filter((e) => e !== email));
const handleSubmit = async () => {
const finalEmails = commitDraft();
if (finalEmails.length === 0) return;
try {
await addMembers.mutateAsync(finalEmails);
toast.success(
finalEmails.length === 1
? 'Invitation sent'
: `${finalEmails.length} invitations sent`,
);
onClose();
} catch (err) {
toast.error(toUserMessage(err));
}
};
const canSubmit =
!addMembers.isPending &&
(emails.length > 0 || EMAIL_RE.test(draft.trim().toLowerCase()));
return (
<BottomSheet open={open} onClose={onClose} avoidKeyboard maxHeight="60%">
<View className="px-5 pb-4">
<Text className="text-white text-lg font-semibold">Add members</Text>
<Text className="text-white/50 text-sm mt-1">
Existing users join right away; others get an email invite.
</Text>
{emails.length > 0 ? (
<View className="flex-row flex-wrap gap-2 mt-4">
{emails.map((email) => (
<Pressable
key={email}
onPress={() => removeEmail(email)}
className="flex-row items-center bg-white/10 rounded-full pl-3 pr-2 py-1"
>
<Text className="text-white text-sm">{email}</Text>
<Text className="text-white/50 text-base ml-1">×</Text>
</Pressable>
))}
</View>
) : null}
<TextInput
value={draft}
onChangeText={(text) => {
if (text.endsWith(',') || text.endsWith(' ')) {
setDraft(text);
commitDraft();
} else {
setDraft(text);
}
}}
placeholder="name@example.com"
placeholderTextColor="rgba(255,255,255,0.4)"
autoFocus
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
returnKeyType="done"
onSubmitEditing={() => commitDraft()}
editable={!addMembers.isPending}
className="text-white text-base bg-white/10 rounded-xl px-4 py-3 mt-4"
/>
<Pressable
onPress={handleSubmit}
disabled={!canSubmit}
className={cn(
'mt-4 rounded-xl py-3 items-center',
canSubmit ? 'bg-white' : 'bg-white/20',
)}
>
<Text
className={cn(
'text-base font-semibold',
canSubmit ? 'text-black' : 'text-white/40',
)}
>
{addMembers.isPending ? 'Sending…' : 'Send invites'}
</Text>
</Pressable>
</View>
</BottomSheet>
);
}
@@ -0,0 +1,281 @@
import { useState } from 'react';
import { Linking, Pressable, Text, View } from 'react-native';
import { ExternalLink } from 'lucide-react-native';
import { toast } from 'sonner-native';
import type { BillingCadence, BillingStatus } from '@/api/types';
import {
useCreateCheckoutSession,
useCreatePortalSession,
useNetworkBilling,
useNetworkUsage,
} from '@/hooks/use-billing';
import { useIsNetworkAdmin } from '@/hooks/use-networks';
import { toUserMessage } from '@/lib/errors';
import { cn } from '@/lib/utils';
function formatCents(cents: number): string {
if (cents % 100 === 0) return `$${cents / 100}`;
return `$${(cents / 100).toFixed(2)}`;
}
function formatDate(date: Date): string {
return date.toLocaleDateString(undefined, {
month: 'long',
day: 'numeric',
year: 'numeric',
});
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<View className="flex-row items-center gap-3 px-1 py-2">
<Text className="text-muted-foreground text-sm">{label}</Text>
<View className="flex-1" />
<View>{value}</View>
</View>
);
}
/**
* Plan + usage summary for every member, plus admin-only upgrade/manage
* controls. Mirrors desktop's BillingSection `/usage` powers the
* everyone-visible summary; `/billing` (admin-gated) drives the controls.
* Stripe checkout/portal URLs are opened in the system browser.
*/
export function BillingSection({ networkId }: { networkId: string }) {
const isAdmin = useIsNetworkAdmin(networkId);
const { data: usage } = useNetworkUsage(networkId);
const isPro = usage?.plan === 'pro';
return (
<View className="px-3">
<Text className="text-foreground text-base font-semibold pt-2 pb-2">
Plan &amp; billing
</Text>
<InfoRow
label="Plan"
value={
<Text className="text-foreground text-sm font-medium">
{isPro ? 'Llink Pro' : 'Llink Free'}
</Text>
}
/>
{!isPro && usage?.limit != null ? (
<InfoRow
label="Todays messages"
value={
<Text className="text-foreground text-sm tabular-nums">
{usage.used} / {usage.limit}
</Text>
}
/>
) : null}
{isAdmin ? <AdminBillingControls networkId={networkId} /> : null}
</View>
);
}
function AdminBillingControls({ networkId }: { networkId: string }) {
const {
data: billing,
isLoading,
error,
} = useNetworkBilling(networkId, true);
if (isLoading || !billing) {
return (
<Text className="text-muted-foreground text-sm px-1 py-2">
{error ? `Couldnt load billing: ${toUserMessage(error)}` : 'Loading…'}
</Text>
);
}
return billing.plan === 'pro' ? (
<ProBilling networkId={networkId} billing={billing} />
) : (
<FreeBilling networkId={networkId} billing={billing} />
);
}
function FreeBilling({
networkId,
billing,
}: {
networkId: string;
billing: BillingStatus;
}) {
const createCheckout = useCreateCheckoutSession(networkId);
const [cadence, setCadence] = useState<BillingCadence>('annual');
const annualPerSeatMonthlyCents = Math.round(billing.price_annual_cents / 12);
const savingsPct = Math.round(
(1 - annualPerSeatMonthlyCents / billing.price_monthly_cents) * 100,
);
const handleUpgrade = async () => {
try {
const { url } = await createCheckout.mutateAsync(cadence);
await Linking.openURL(url);
} catch (err) {
toast.error(toUserMessage(err));
}
};
return (
<View className="mt-2 gap-2">
<CadenceOption
label="Annual"
note="Billed annually"
perSeatCents={annualPerSeatMonthlyCents}
badge={savingsPct > 0 ? `Save ${savingsPct}%` : undefined}
selected={cadence === 'annual'}
onPress={() => setCadence('annual')}
/>
<CadenceOption
label="Monthly"
note="Billed monthly · cancel anytime"
perSeatCents={billing.price_monthly_cents}
selected={cadence === 'monthly'}
onPress={() => setCadence('monthly')}
/>
<Pressable
onPress={handleUpgrade}
disabled={createCheckout.isPending}
className="mt-2 rounded-xl bg-primary py-3 items-center"
>
<Text className="text-primary-foreground text-base font-semibold">
{createCheckout.isPending ? 'Opening Stripe…' : 'Upgrade to Pro'}
</Text>
</Pressable>
</View>
);
}
function CadenceOption({
label,
note,
perSeatCents,
badge,
selected,
onPress,
}: {
label: string;
note: string;
perSeatCents: number;
badge?: string;
selected: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
className={cn(
'flex-row items-center gap-3 rounded-xl border px-4 py-3',
selected ? 'border-primary bg-accent' : 'border-border',
)}
>
<View
className={cn(
'h-5 w-5 rounded-full border-2',
selected ? 'border-primary bg-primary' : 'border-muted-foreground',
)}
/>
<View className="flex-1">
<View className="flex-row items-center gap-2">
<Text className="text-foreground text-sm font-medium">{label}</Text>
{badge ? (
<View className="bg-primary rounded-full px-2 py-0.5">
<Text className="text-primary-foreground text-[10px] font-semibold">
{badge}
</Text>
</View>
) : null}
</View>
<Text className="text-muted-foreground text-xs">{note}</Text>
</View>
<View className="items-end">
<Text className="text-foreground text-sm font-medium">
{formatCents(perSeatCents)}
</Text>
<Text className="text-muted-foreground text-xs">per seat / mo</Text>
</View>
</Pressable>
);
}
function ProBilling({
networkId,
billing,
}: {
networkId: string;
billing: BillingStatus;
}) {
const createPortal = useCreatePortalSession(networkId);
const cadenceLabel = billing.cadence === 'annual' ? 'Annual' : 'Monthly';
const perSeatCents =
billing.cadence === 'annual'
? Math.round(billing.price_annual_cents / 12)
: billing.price_monthly_cents;
const renewal = billing.current_period_end
? formatDate(billing.current_period_end)
: null;
const handleManage = async () => {
try {
const { url } = await createPortal.mutateAsync();
await Linking.openURL(url);
} catch (err) {
toast.error(toUserMessage(err));
}
};
return (
<View className="mt-2">
{billing.cancel_at_period_end && renewal ? (
<Text className="text-destructive text-sm py-2">
Your subscription downgrades to Free on {renewal}.
</Text>
) : null}
{billing.plan_status === 'past_due' ? (
<Text className="text-destructive text-sm py-2">
Your last payment failed. Update your payment method to keep Pro
active.
</Text>
) : null}
<InfoRow
label="Billing"
value={
<Text className="text-foreground text-sm">
{`${cadenceLabel} · ${formatCents(perSeatCents)} / seat / mo`}
</Text>
}
/>
<InfoRow
label="Seats"
value={<Text className="text-foreground text-sm">{billing.seats}</Text>}
/>
{renewal ? (
<InfoRow
label={billing.cancel_at_period_end ? 'Ends' : 'Renews'}
value={<Text className="text-foreground text-sm">{renewal}</Text>}
/>
) : null}
<Pressable
onPress={handleManage}
disabled={createPortal.isPending}
className="mt-2 flex-row items-center justify-center gap-2 rounded-xl border border-border py-3"
>
<ExternalLink color="#fafafa" size={15} />
<Text className="text-foreground text-base font-medium">
{createPortal.isPending ? 'Opening Stripe…' : 'Manage subscription'}
</Text>
</Pressable>
</View>
);
}
@@ -0,0 +1,218 @@
import { useState } from 'react';
import { Alert, Pressable, ScrollView, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Mail, Shield, UserPlus, X } from 'lucide-react-native';
import { toast } from 'sonner-native';
import type { Human } from '@/api/types';
import { useNetwork } from '@/hooks/use-networks';
import {
useNetworkInvitations,
useRemoveMember,
useRevokeInvitation,
} from '@/hooks/use-member-management';
import { useAuthStore } from '@/stores/auth-store';
import { Avatar } from '@/components/Avatar';
import { toUserMessage } from '@/lib/errors';
import type { RootStackScreenProps } from '@/navigation/types';
import { AddMembersSheet } from './AddMembersSheet';
import { BillingSection } from './BillingSection';
export function NetworkSettingsScreen({
route,
navigation,
}: RootStackScreenProps<'NetworkSettings'>) {
const { networkId } = route.params;
const network = useNetwork(networkId);
const { data: invitations, error: invitationsError } =
useNetworkInvitations(networkId);
const currentUserId = useAuthStore((s) => s.user?.id);
const isAdmin = !!currentUserId && network?.admin_human.id === currentUserId;
const [addOpen, setAddOpen] = useState(false);
const removeMember = useRemoveMember(networkId);
const members = network?.humans ?? [];
const pending = invitations ?? [];
const handleRemove = (human: Human) => {
Alert.alert(
`Remove ${human.email_prefix}?`,
"They'll lose access to this network's streams and files. Content they posted stays in the network.",
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: async () => {
try {
await removeMember.mutateAsync(human.id);
toast.success(`Removed ${human.email}`);
} catch (err) {
toast.error(toUserMessage(err));
}
},
},
],
);
};
return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="flex-row items-center px-3 py-3 border-b border-border">
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
<Text className="text-foreground text-2xl"></Text>
</Pressable>
<Text className="flex-1 text-center text-foreground text-base font-semibold">
{network?.name ?? 'Network'}
</Text>
<View className="w-8" />
</View>
<ScrollView className="flex-1">
<View className="flex-row items-center justify-between px-4 pt-5 pb-2">
<View>
<Text className="text-foreground text-base font-semibold">
Members
</Text>
<Text className="text-muted-foreground text-xs">
{members.length} {members.length === 1 ? 'member' : 'members'}
</Text>
</View>
{isAdmin ? (
<Pressable
onPress={() => setAddOpen(true)}
className="flex-row items-center bg-primary rounded-full px-3 py-2"
>
<UserPlus size={14} color="#000000" />
<Text className="text-primary-foreground text-sm font-semibold ml-1">
Add
</Text>
</Pressable>
) : null}
</View>
<View className="px-2">
{members.map((human) => {
const isRowAdmin = human.id === network?.admin_human.id;
const canRemove =
isAdmin && !isRowAdmin && human.id !== currentUserId;
return (
<View
key={human.id}
className="flex-row items-center gap-3 px-2 py-3"
>
<Avatar humanId={human.id} humans={members} size="md" />
<View className="flex-1">
<Text className="text-foreground text-sm font-medium">
{human.email_prefix}
</Text>
<Text className="text-muted-foreground text-xs">
{human.email}
</Text>
</View>
{isRowAdmin ? (
<View className="flex-row items-center bg-muted rounded-full px-2 py-1">
<Shield size={12} color="#a6a6a6" />
<Text className="text-muted-foreground text-xs ml-1">
Admin
</Text>
</View>
) : null}
{canRemove ? (
<Pressable
onPress={() => handleRemove(human)}
hitSlop={8}
className="p-1"
accessibilityLabel={`Remove ${human.email}`}
>
<X size={16} color="#a6a6a6" />
</Pressable>
) : null}
</View>
);
})}
</View>
{isAdmin ? (
<View className="mt-4">
<Text className="text-foreground text-base font-semibold px-4 pt-2 pb-2">
Pending invitations
</Text>
{invitationsError ? (
<Text className="text-muted-foreground text-xs px-4 py-2">
Couldnt load pending invitations.
</Text>
) : pending.length === 0 ? (
<Text className="text-muted-foreground text-xs px-4 py-2">
No pending invitations.
</Text>
) : (
<View className="px-2">
{pending.map((inv) => (
<PendingInvitationRow
key={inv.email}
email={inv.email}
networkId={networkId}
/>
))}
</View>
)}
</View>
) : null}
<View className="mt-4 border-t border-border pt-2">
<BillingSection networkId={networkId} />
</View>
</ScrollView>
{isAdmin ? (
<AddMembersSheet
open={addOpen}
onClose={() => setAddOpen(false)}
networkId={networkId}
/>
) : null}
</SafeAreaView>
);
}
function PendingInvitationRow({
email,
networkId,
}: {
email: string;
networkId: string;
}) {
const revokeInvitation = useRevokeInvitation(networkId);
const handleRevoke = async () => {
try {
await revokeInvitation.mutateAsync(email);
toast.success(`Invitation to ${email} revoked`);
} catch (err) {
toast.error(toUserMessage(err));
}
};
return (
<View className="flex-row items-center gap-3 px-2 py-3">
<View className="h-10 w-10 items-center justify-center rounded-full bg-muted">
<Mail size={16} color="#a6a6a6" />
</View>
<View className="flex-1">
<Text className="text-foreground text-sm" numberOfLines={1}>
{email}
</Text>
<Text className="text-muted-foreground text-xs">Pending</Text>
</View>
<Pressable
onPress={handleRevoke}
disabled={revokeInvitation.isPending}
hitSlop={8}
className="p-1"
accessibilityLabel={`Revoke invitation to ${email}`}
>
<X size={16} color="#a6a6a6" />
</Pressable>
</View>
);
}
@@ -0,0 +1,90 @@
import { useState } from 'react';
import { Pressable, Text, TextInput, View } from 'react-native';
import { toast } from 'sonner-native';
import { BottomSheet } from '@/components/BottomSheet';
import { useCreateNetwork } from '@/hooks/use-invitations';
import { toUserMessage } from '@/lib/errors';
import { cn } from '@/lib/utils';
interface CreateNetworkSheetProps {
open: boolean;
onClose: () => void;
/** Called with the new network's id once creation succeeds. */
onCreated?: (networkId: string) => void;
}
/**
* Minimal "name your network" sheet. Mirrors desktop's create-network flow in
* `network-selector.tsx` single text field, creator becomes admin.
*/
export function CreateNetworkSheet({
open,
onClose,
onCreated,
}: CreateNetworkSheetProps) {
const [name, setName] = useState('');
const createNetwork = useCreateNetwork();
// Reset the field each time the sheet opens fresh.
const [prevOpen, setPrevOpen] = useState(open);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) setName('');
}
const trimmed = name.trim();
const canCreate = trimmed.length > 0 && !createNetwork.isPending;
const handleCreate = async () => {
if (!canCreate) return;
try {
const network = await createNetwork.mutateAsync({ name: trimmed });
onClose();
onCreated?.(network.id);
} catch (err) {
toast.error(toUserMessage(err));
}
};
return (
<BottomSheet open={open} onClose={onClose} avoidKeyboard maxHeight="50%">
<View className="px-5 pb-4">
<Text className="text-white text-lg font-semibold">New network</Text>
<Text className="text-white/50 text-sm mt-1">
Youll be the admin and can invite people next.
</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder="Network name"
placeholderTextColor="rgba(255,255,255,0.4)"
autoFocus
autoCapitalize="words"
returnKeyType="done"
onSubmitEditing={handleCreate}
editable={!createNetwork.isPending}
className="text-white text-base bg-white/10 rounded-xl px-4 py-3 mt-4"
/>
<Pressable
onPress={handleCreate}
disabled={!canCreate}
className={cn(
'mt-4 rounded-xl py-3 items-center',
canCreate ? 'bg-white' : 'bg-white/20',
)}
>
<Text
className={cn(
'text-base font-semibold',
canCreate ? 'text-black' : 'text-white/40',
)}
>
{createNetwork.isPending ? 'Creating…' : 'Create network'}
</Text>
</Pressable>
</View>
</BottomSheet>
);
}
+19 -8
View File
@@ -13,6 +13,7 @@ import {
SafeAreaProvider, SafeAreaProvider,
SafeAreaView, SafeAreaView,
} from 'react-native-safe-area-context'; } from 'react-native-safe-area-context';
import { Avatar } from '@/components/Avatar';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
const SCREEN_WIDTH = Dimensions.get('window').width; const SCREEN_WIDTH = Dimensions.get('window').width;
@@ -26,7 +27,12 @@ interface DrawerProps {
onNavigateSettings: () => void; onNavigateSettings: () => void;
} }
export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) { export function Drawer({
open,
onClose,
onNavigateAccount,
onNavigateSettings,
}: DrawerProps) {
// Lazy-init so each Animated.Value is created once; the setters are never // Lazy-init so each Animated.Value is created once; the setters are never
// called — the values are mutated internally by the native driver. // called — the values are mutated internally by the native driver.
const [translateX] = useState(() => new Animated.Value(-DRAWER_WIDTH)); const [translateX] = useState(() => new Animated.Value(-DRAWER_WIDTH));
@@ -53,8 +59,6 @@ export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
const signOut = useAuthStore((s) => s.signOut); const signOut = useAuthStore((s) => s.signOut);
const isSigningOut = useAuthStore((s) => s.isSigningOut); const isSigningOut = useAuthStore((s) => s.isSigningOut);
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
return ( return (
<Modal <Modal
visible={open} visible={open}
@@ -85,11 +89,11 @@ export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
> >
<SafeAreaView edges={['top', 'bottom', 'left']} className="flex-1"> <SafeAreaView edges={['top', 'bottom', 'left']} className="flex-1">
<View className="border-sidebar-border border-b px-5 py-5 flex-row items-center gap-3"> <View className="border-sidebar-border border-b px-5 py-5 flex-row items-center gap-3">
<View className="bg-sidebar-accent h-10 w-10 items-center justify-center rounded-full"> <Avatar
<Text className="text-sidebar-accent-foreground text-sm font-semibold"> humanId={user?.id}
{initials} humans={user ? [user] : undefined}
</Text> size="md"
</View> />
<View className="flex-1"> <View className="flex-1">
<Text <Text
className="text-sidebar-foreground text-base font-medium" className="text-sidebar-foreground text-base font-medium"
@@ -114,6 +118,13 @@ export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
onNavigateAccount(); onNavigateAccount();
}} }}
/> />
<DrawerRow
label="Settings"
onPress={() => {
onClose();
onNavigateSettings();
}}
/>
</View> </View>
<View className="border-sidebar-border border-t px-2 py-2"> <View className="border-sidebar-border border-t px-2 py-2">
@@ -8,22 +8,28 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import type { Network } from '@/api/types'; import { Plus } from 'lucide-react-native';
import { toast } from 'sonner-native';
import type { Invitation, Network } from '@/api/types';
import { useNetworks } from '@/hooks/use-networks'; import { useNetworks } from '@/hooks/use-networks';
import { useAcceptInvitation, useMyInvitations } from '@/hooks/use-invitations';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { toUserMessage } from '@/lib/errors'; import { toUserMessage } from '@/lib/errors';
import type { RootStackScreenProps } from '@/navigation/types'; import type { RootStackScreenProps } from '@/navigation/types';
import { Avatar } from '@/components/Avatar';
import { FlowyLogo } from '@/components/FlowyLogo'; import { FlowyLogo } from '@/components/FlowyLogo';
import { ListSeparator } from '@/components/ListSeparator'; import { ListSeparator } from '@/components/ListSeparator';
import { Drawer } from './Drawer'; import { Drawer } from './Drawer';
import { CreateNetworkSheet } from './CreateNetworkSheet';
export function NetworkListScreen({ export function NetworkListScreen({
navigation, navigation,
}: RootStackScreenProps<'NetworkList'>) { }: RootStackScreenProps<'NetworkList'>) {
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const { data, isLoading, refetch, error } = useNetworks(); const { data, isLoading, refetch, error } = useNetworks();
const { data: invitations, refetch: refetchInvitations } = useMyInvitations();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
// Local refreshing state — driving RefreshControl from react-query's // Local refreshing state — driving RefreshControl from react-query's
// isRefetching can leave the native spinner visually stuck after the // isRefetching can leave the native spinner visually stuck after the
@@ -32,11 +38,13 @@ export function NetworkListScreen({
const onRefresh = useCallback(async () => { const onRefresh = useCallback(async () => {
setRefreshing(true); setRefreshing(true);
try { try {
await refetch(); await Promise.all([refetch(), refetchInvitations()]);
} finally { } finally {
setRefreshing(false); setRefreshing(false);
} }
}, [refetch]); }, [refetch, refetchInvitations]);
const pendingInvitations = invitations ?? [];
return ( return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}> <SafeAreaView className="flex-1 bg-background" edges={['top']}>
@@ -44,14 +52,21 @@ export function NetworkListScreen({
<Pressable <Pressable
onPress={() => setDrawerOpen(true)} onPress={() => setDrawerOpen(true)}
accessibilityLabel="Open menu" accessibilityLabel="Open menu"
className="bg-muted h-9 w-9 items-center justify-center rounded-full"
> >
<Text className="text-muted-foreground text-xs font-semibold"> <Avatar
{initials} humanId={user?.id}
</Text> humans={user ? [user] : undefined}
size="sm"
/>
</Pressable> </Pressable>
<FlowyLogo /> <FlowyLogo />
<View className="w-9" /> <Pressable
onPress={() => setCreateOpen(true)}
accessibilityLabel="Create network"
className="bg-muted h-9 w-9 items-center justify-center rounded-full"
>
<Plus size={18} color="#fafafa" />
</Pressable>
</View> </View>
{isLoading ? ( {isLoading ? (
@@ -67,16 +82,21 @@ export function NetworkListScreen({
<Text className="text-foreground font-medium">Retry</Text> <Text className="text-foreground font-medium">Retry</Text>
</Pressable> </Pressable>
</View> </View>
) : !data || data.length === 0 ? ( ) : (!data || data.length === 0) && pendingInvitations.length === 0 ? (
<EmptyState /> <EmptyState onCreate={() => setCreateOpen(true)} />
) : ( ) : (
<FlatList <FlatList
data={data} data={data ?? []}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
refreshControl={ refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} /> <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
} }
ItemSeparatorComponent={ListSeparator} ItemSeparatorComponent={ListSeparator}
ListHeaderComponent={
pendingInvitations.length > 0 ? (
<InvitationsSection invitations={pendingInvitations} />
) : null
}
renderItem={({ item }) => ( renderItem={({ item }) => (
<NetworkCard <NetworkCard
network={item} network={item}
@@ -94,10 +114,65 @@ export function NetworkListScreen({
onNavigateAccount={() => navigation.navigate('Account')} onNavigateAccount={() => navigation.navigate('Account')}
onNavigateSettings={() => navigation.navigate('Settings')} onNavigateSettings={() => navigation.navigate('Settings')}
/> />
<CreateNetworkSheet
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={(networkId) =>
navigation.navigate('StreamList', { networkId })
}
/>
</SafeAreaView> </SafeAreaView>
); );
} }
function InvitationsSection({ invitations }: { invitations: Invitation[] }) {
return (
<View className="border-b border-border">
<Text className="text-muted-foreground text-xs uppercase tracking-wide px-4 pt-4 pb-1">
Invitations
</Text>
{invitations.map((invitation) => (
<InvitationCard key={invitation.network_id} invitation={invitation} />
))}
</View>
);
}
function InvitationCard({ invitation }: { invitation: Invitation }) {
const acceptInvitation = useAcceptInvitation();
const handleAccept = async () => {
try {
await acceptInvitation.mutateAsync(invitation.network_id);
} catch (err) {
toast.error(toUserMessage(err));
}
};
return (
<View className="flex-row items-center justify-between px-4 py-4">
<View className="flex-1 pr-3">
<Text className="text-foreground text-base font-semibold">
{invitation.network_name}
</Text>
<Text className="text-muted-foreground text-sm">
Youve been invited to join
</Text>
</View>
<Pressable
onPress={handleAccept}
disabled={acceptInvitation.isPending}
className="bg-primary rounded-full px-4 py-2"
>
<Text className="text-primary-foreground text-sm font-semibold">
{acceptInvitation.isPending ? 'Joining…' : 'Accept'}
</Text>
</Pressable>
</View>
);
}
function NetworkCard({ function NetworkCard({
network, network,
onPress, onPress,
@@ -124,15 +199,23 @@ function NetworkCard({
); );
} }
function EmptyState() { function EmptyState({ onCreate }: { onCreate: () => void }) {
return ( return (
<View className="flex-1 items-center justify-center px-6"> <View className="flex-1 items-center justify-center px-6">
<Text className="text-foreground text-lg font-medium text-center"> <Text className="text-foreground text-lg font-medium text-center">
You arent in any networks yet. You arent in any networks yet.
</Text> </Text>
<Text className="text-muted-foreground mt-2 text-center"> <Text className="text-muted-foreground mt-2 text-center">
Ask a friend for an invite, or create one on desktop. Create one to get started, or ask a friend for an invite.
</Text> </Text>
<Pressable
onPress={onCreate}
className="bg-primary rounded-full px-5 py-3 mt-6"
>
<Text className="text-primary-foreground font-semibold">
Create a network
</Text>
</Pressable>
</View> </View>
); );
} }
@@ -1,10 +1,68 @@
import { Pressable, Text, View } from 'react-native'; import { useState } from 'react';
import { ActivityIndicator, Alert, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import * as ImagePicker from 'expo-image-picker';
import { Camera } from 'lucide-react-native';
import { toast } from 'sonner-native';
import { apiClient } from '@/api/client';
import { Avatar } from '@/components/Avatar';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { toUserMessage } from '@/lib/errors';
import type { RootStackScreenProps } from '@/navigation/types'; import type { RootStackScreenProps } from '@/navigation/types';
export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) { export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const refreshUser = useAuthStore((s) => s.refreshUser);
const [busy, setBusy] = useState(false);
const pickAndUpload = async () => {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permission.granted) {
toast.error('Photo library access is needed to set an avatar.');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
if (result.canceled) return;
const asset = result.assets[0];
if (!asset) return;
setBusy(true);
try {
await apiClient.uploadAvatar(asset.uri, asset.mimeType ?? 'image/jpeg');
await refreshUser();
toast.success('Avatar updated');
} catch (err) {
toast.error(toUserMessage(err));
} finally {
setBusy(false);
}
};
const removeAvatar = () => {
Alert.alert('Remove avatar?', 'Your initials will be shown instead.', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: async () => {
setBusy(true);
try {
await apiClient.deleteAvatar();
await refreshUser();
} catch (err) {
toast.error(toUserMessage(err));
} finally {
setBusy(false);
}
},
},
]);
};
return ( return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}> <SafeAreaView className="flex-1 bg-background" edges={['top']}>
@@ -18,7 +76,38 @@ export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
<View className="w-8" /> <View className="w-8" />
</View> </View>
<View className="px-6 py-6 gap-4"> <View className="items-center px-6 py-8">
<Pressable
onPress={pickAndUpload}
disabled={busy}
accessibilityLabel="Change profile picture"
className="relative"
>
{busy ? (
<View className="h-24 w-24 items-center justify-center rounded-full bg-muted">
<ActivityIndicator />
</View>
) : (
<Avatar
humanId={user?.id}
humans={user ? [user] : undefined}
size="xl"
className="bg-muted"
/>
)}
<View className="absolute bottom-0 right-0 h-7 w-7 items-center justify-center rounded-full bg-primary border-2 border-background">
<Camera size={13} color="#000000" />
</View>
</Pressable>
{user?.avatar_object_id ? (
<Pressable onPress={removeAvatar} disabled={busy} className="mt-3">
<Text className="text-destructive text-sm">Remove photo</Text>
</Pressable>
) : null}
</View>
<View className="px-6 gap-4">
<Field label="Email" value={user?.email ?? '—'} /> <Field label="Email" value={user?.email ?? '—'} />
</View> </View>
</SafeAreaView> </SafeAreaView>
@@ -1,10 +1,47 @@
import { Pressable, Text, View } from 'react-native'; import { useState } from 'react';
import { Pressable, Switch, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import Constants from 'expo-constants';
import { apiClient } from '@/api/client';
import { useAuthStore } from '@/stores/auth-store';
import { logError, toUserMessage } from '@/lib/errors';
import { toast } from 'sonner-native';
import type { RootStackScreenProps } from '@/navigation/types'; import type { RootStackScreenProps } from '@/navigation/types';
export function SettingsScreen({ export function SettingsScreen({
navigation, navigation,
}: RootStackScreenProps<'Settings'>) { }: RootStackScreenProps<'Settings'>) {
const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut);
const isSigningOut = useAuthStore((s) => s.isSigningOut);
const [emailNotifications, setEmailNotifications] = useState(
user?.email_notifications_enabled ?? true,
);
const version = Constants.expoConfig?.version ?? '—';
// Optimistic toggle — flip the local + auth-store state immediately, roll
// back on failure. Mirrors desktop's settings-page handler.
const handleToggleEmailNotifications = async (checked: boolean) => {
setEmailNotifications(checked);
useAuthStore.setState((state) => ({
user: state.user
? { ...state.user, email_notifications_enabled: checked }
: null,
}));
try {
await apiClient.updateSettings({ email_notifications_enabled: checked });
} catch (err) {
setEmailNotifications(!checked);
useAuthStore.setState((state) => ({
user: state.user
? { ...state.user, email_notifications_enabled: !checked }
: null,
}));
toast.error(toUserMessage(err));
logError(err, { scope: 'settings.emailNotifications' });
}
};
return ( return (
<SafeAreaView className="flex-1 bg-background" edges={['top']}> <SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="flex-row items-center px-3 py-3 border-b border-border"> <View className="flex-row items-center px-3 py-3 border-b border-border">
@@ -17,11 +54,55 @@ export function SettingsScreen({
<View className="w-8" /> <View className="w-8" />
</View> </View>
<View className="flex-1 items-center justify-center px-6"> <View className="px-4 py-4">
<Text className="text-muted-foreground text-center"> <SettingsGroup title="Notifications">
Theme, notifications, and account preferences land here later. <View className="flex-row items-center justify-between px-4 py-3">
</Text> <Text className="text-foreground text-base">
Email notifications
</Text>
<Switch
value={emailNotifications}
onValueChange={handleToggleEmailNotifications}
/>
</View>
</SettingsGroup>
<SettingsGroup title="About">
<View className="flex-row items-center justify-between px-4 py-3">
<Text className="text-foreground text-base">Version</Text>
<Text className="text-muted-foreground text-base">{version}</Text>
</View>
</SettingsGroup>
<View className="mt-4">
<Pressable
onPress={() => void signOut()}
disabled={isSigningOut}
className="px-4 py-3 active:bg-accent rounded-xl"
>
<Text className="text-destructive text-base font-medium">
{isSigningOut ? 'Signing out…' : 'Sign out'}
</Text>
</Pressable>
</View>
</View> </View>
</SafeAreaView> </SafeAreaView>
); );
} }
function SettingsGroup({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<View className="mb-2">
<Text className="text-muted-foreground text-xs uppercase tracking-wide px-4 pt-4 pb-1">
{title}
</Text>
<View className="bg-muted/40 rounded-xl overflow-hidden">{children}</View>
</View>
);
}
@@ -1,22 +1,10 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Text, View } from 'react-native'; import { Text, View } from 'react-native';
import { import { HelpCircle } from 'lucide-react-native';
FileIcon,
HelpCircle,
ScrollText,
BookOpen,
type LucideIcon,
} from 'lucide-react-native';
import type { Particle } from '@/api/types'; import type { Particle } from '@/api/types';
import { useNetwork } from '@/hooks/use-networks'; import { useNetwork } from '@/hooks/use-networks';
import { resolveHumanDisplay } from '@/lib/humans'; import { resolveHumanDisplay } from '@/lib/humans';
const TYPE_META: Record<string, { icon: LucideIcon; label: string }> = {
quest: { icon: ScrollText, label: 'Quest' },
paper: { icon: BookOpen, label: 'Paper' },
file: { icon: FileIcon, label: 'File' },
};
const PLACEHOLDER_DURATION_MS = 5000; const PLACEHOLDER_DURATION_MS = 5000;
interface FallbackParticleViewProps { interface FallbackParticleViewProps {
@@ -26,6 +14,10 @@ interface FallbackParticleViewProps {
onEnded: () => void; onEnded: () => void;
} }
// Catch-all for particle types this client version doesn't render with a
// dedicated view (e.g. a folder slipping into a stream, or a future type a
// newer client wrote). Known content types — media, text, task, paper, file —
// each have their own view in StreamView's switch.
export function FallbackParticleView({ export function FallbackParticleView({
particle, particle,
networkId, networkId,
@@ -37,25 +29,8 @@ export function FallbackParticleView({
particle.created_by_human_id, particle.created_by_human_id,
network?.humans, network?.humans,
); );
const meta = TYPE_META[particle.type] ?? { const Icon = HelpCircle;
icon: HelpCircle, const title = particle.type === 'folder' ? particle.properties.name : null;
label: particle.type,
};
const Icon = meta.icon;
const title = (() => {
switch (particle.type) {
case 'quest':
return particle.properties.title;
case 'paper':
return particle.properties.title;
case 'file':
return particle.properties.filename;
case 'folder':
return particle.properties.name;
default:
return null;
}
})();
useEffect(() => { useEffect(() => {
if (paused) return; if (paused) return;
@@ -70,7 +45,7 @@ export function FallbackParticleView({
<Icon color="rgba(255,255,255,0.7)" size={22} strokeWidth={1.5} /> <Icon color="rgba(255,255,255,0.7)" size={22} strokeWidth={1.5} />
<View className="flex-1"> <View className="flex-1">
<Text className="text-white text-base font-semibold"> <Text className="text-white text-base font-semibold">
{meta.label} {particle.type}
</Text> </Text>
{title ? ( {title ? (
<Text className="text-white/70 text-sm" numberOfLines={2}> <Text className="text-white/70 text-sm" numberOfLines={2}>
@@ -0,0 +1,103 @@
import { useEffect, useRef, useState } from 'react';
import { Linking, Pressable, Text, View } from 'react-native';
import { Download, FileIcon } from 'lucide-react-native';
import { toast } from 'sonner-native';
import type { Particle } from '@/api/types';
import { apiClient } from '@/api/client';
import { toUserMessage } from '@/lib/errors';
type FileParticle = Extract<Particle, { type: 'file' }>;
interface FileParticleViewProps {
particle: FileParticle;
paused: boolean;
onEnded: () => void;
}
// Files don't auto-play; give the reader a beat to act before advancing.
const DWELL_DURATION_MS = 8000;
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(0)} KB`;
const mb = kb / 1024;
if (mb < 1024) return `${mb.toFixed(1)} MB`;
return `${(mb / 1024).toFixed(1)} GB`;
}
/**
* File particle: name, size, and a download action. Tapping resolves a signed
* URL and opens it (the OS handles the download / preview). Mirrors desktop's
* file attachment, minus in-app preview.
*/
export function FileParticleView({
particle,
paused,
onEnded,
}: FileParticleViewProps) {
const { filename, size_bytes, object_id } = particle.properties;
const [downloading, setDownloading] = useState(false);
const elapsedRef = useRef(0);
// Pause the dwell while a download is being resolved so the stream doesn't
// advance out from under the user mid-tap.
useEffect(() => {
if (paused || downloading) return;
const start = Date.now();
const interval = setInterval(() => {
elapsedRef.current += Date.now() - start;
if (elapsedRef.current >= DWELL_DURATION_MS) {
clearInterval(interval);
onEnded();
}
}, 250);
return () => clearInterval(interval);
}, [paused, downloading, onEnded, particle.id]);
const handleDownload = async () => {
setDownloading(true);
try {
const url = await apiClient.getParticleDownloadUrl(object_id);
const canOpen = await Linking.canOpenURL(url);
if (!canOpen) throw new Error('Could not open this file.');
await Linking.openURL(url);
} catch (err) {
toast.error(toUserMessage(err));
} finally {
setDownloading(false);
}
};
return (
<View className="flex-1 items-center justify-center px-8">
<View className="bg-white/10 w-full max-w-sm rounded-2xl px-5 py-5">
<View className="flex-row items-center gap-3">
<FileIcon color="rgba(255,255,255,0.7)" size={26} strokeWidth={1.5} />
<View className="flex-1">
<Text
className="text-white text-base font-semibold"
numberOfLines={2}
>
{filename}
</Text>
<Text className="text-white/50 text-xs mt-0.5">
{formatBytes(size_bytes)}
</Text>
</View>
</View>
<Pressable
onPress={handleDownload}
disabled={downloading}
className="mt-5 flex-row items-center justify-center gap-2 rounded-xl bg-white py-3"
>
<Download color="#000000" size={16} strokeWidth={2} />
<Text className="text-black text-base font-semibold">
{downloading ? 'Opening…' : 'Download'}
</Text>
</Pressable>
</View>
</View>
);
}
@@ -0,0 +1,79 @@
import { useEffect, useRef } from 'react';
import { ScrollView, Text, View } from 'react-native';
import type { Particle } from '@/api/types';
import { MarkdownBody } from '@/components/MarkdownBody';
import { useStreamSafeArea } from './stream-safe-area';
type PaperParticle = Extract<Particle, { type: 'paper' }>;
interface PaperParticleViewProps {
particle: PaperParticle;
paused: boolean;
onEnded: () => void;
onProgress: (ratio: number) => void;
}
// Papers are longer-form documents, so they read at the text cadence but with a
// higher cap — the reader can still scroll at their own pace while the timer
// ticks toward auto-advance.
const CHARS_PER_MINUTE = 1000;
const MIN_DURATION_S = 4;
const MAX_DURATION_S = 30;
const TICK_MS = 100;
function computeReadDuration(text: string): number {
const base = (text.length / CHARS_PER_MINUTE) * 60;
return Math.min(Math.max(base, MIN_DURATION_S), MAX_DURATION_S);
}
/**
* Read-only paper (document) view. Mirrors desktop's paper rendering: a title
* heading above markdown body, scrollable, with a length-based dwell timer.
*/
export function PaperParticleView({
particle,
paused,
onEnded,
onProgress,
}: PaperParticleViewProps) {
const { title, content } = particle.properties;
const safe = useStreamSafeArea();
const durationS = computeReadDuration(content);
const elapsedRef = useRef(0);
useEffect(() => {
elapsedRef.current = 0;
onProgress(0);
}, [particle.id, onProgress]);
useEffect(() => {
if (paused) return;
const interval = setInterval(() => {
elapsedRef.current += TICK_MS / 1000;
const ratio = Math.min(elapsedRef.current / durationS, 1);
onProgress(ratio);
if (ratio >= 1) {
clearInterval(interval);
onEnded();
}
}, TICK_MS);
return () => clearInterval(interval);
}, [paused, durationS, onEnded, onProgress, particle.id]);
return (
<View
className="flex-1 items-center justify-center px-6"
style={{ paddingTop: safe.top + 16, paddingBottom: safe.bottom + 16 }}
>
<ScrollView
className="max-h-full w-full max-w-xl rounded-2xl bg-white/10"
contentContainerClassName="px-5 py-5"
showsVerticalScrollIndicator
indicatorStyle="white"
>
<Text className="text-white text-2xl font-semibold mb-3">{title}</Text>
<MarkdownBody content={content} />
</ScrollView>
</View>
);
}
@@ -3,7 +3,7 @@ import { Pressable, Text, View } from 'react-native';
import { Plus } from 'lucide-react-native'; import { Plus } from 'lucide-react-native';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types'; import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
import { resolveHumanDisplay } from '@/lib/humans'; import { Avatar } from '@/components/Avatar';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const EMOJI_SET = new Set<string>(REACTION_EMOJIS); const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
@@ -77,7 +77,6 @@ export function ReactionStack({
{activeTextKeys.map((text) => { {activeTextKeys.map((text) => {
const reactors = reactions?.[text] ?? []; const reactors = reactions?.[text] ?? [];
const isMine = reactors.includes(currentHumanId); const isMine = reactors.includes(currentHumanId);
const firstReactor = resolveHumanDisplay(reactors[0], humans);
return ( return (
<Pressable <Pressable
key={text} key={text}
@@ -93,11 +92,7 @@ export function ReactionStack({
: null, : null,
]} ]}
> >
<View className="bg-white/15 h-5 w-5 items-center justify-center rounded-full"> <Avatar humanId={reactors[0]} humans={humans} size="xs" />
<Text className="text-white text-[9px] font-semibold">
{firstReactor.initials}
</Text>
</View>
<Text className="text-white/90 text-xs" numberOfLines={1}> <Text className="text-white/90 text-xs" numberOfLines={1}>
{text} {text}
</Text> </Text>
@@ -0,0 +1,50 @@
import { Text, View } from 'react-native';
import { Clock, Pause } from 'lucide-react-native';
interface StreamStatusPillsProps {
paused: boolean;
/** Null when no auto-exit is pending; ms remaining otherwise. */
exitRemainingMs: number | null;
}
/**
* Compact playback-state pills shown in the right chrome margin so they never
* overlay the particle canvas. "Paused" collapses to an icon-only badge; the
* exit countdown pairs a clock icon with tabular-number seconds so the digit
* tick doesn't reflow neighbors.
*/
export function StreamStatusPills({
paused,
exitRemainingMs,
}: StreamStatusPillsProps) {
if (!paused && exitRemainingMs === null) return null;
return (
<View className="flex-row items-center gap-1.5">
{paused ? (
<View
accessibilityLabel="Paused"
className="bg-white/15 h-6 w-6 items-center justify-center rounded-full"
>
<Pause color="white" size={11} fill="white" strokeWidth={0} />
</View>
) : null}
{exitRemainingMs !== null ? (
<View
accessibilityLabel={`Closing in ${Math.ceil(
exitRemainingMs / 1000,
)} seconds`}
className="bg-white/15 h-6 flex-row items-center gap-1 rounded-full px-2"
>
<Clock color="white" size={11} strokeWidth={2} />
<Text
className="text-white text-[11px] font-semibold"
style={{ fontVariant: ['tabular-nums'] }}
>
{Math.ceil(exitRemainingMs / 1000)}s
</Text>
</View>
) : null}
</View>
);
}
@@ -53,11 +53,15 @@ import {
useStreamComposing, useStreamComposing,
} from './stream-presence-context'; } from './stream-presence-context';
import { TextParticleView } from './TextParticleView'; import { TextParticleView } from './TextParticleView';
import { TaskParticleView } from './TaskParticleView';
import { PaperParticleView } from './PaperParticleView';
import { FileParticleView } from './FileParticleView';
import { MediaParticleView } from './MediaParticleView'; import { MediaParticleView } from './MediaParticleView';
import { DeletedParticleView } from './DeletedParticleView'; import { DeletedParticleView } from './DeletedParticleView';
import { FallbackParticleView } from './FallbackParticleView'; import { FallbackParticleView } from './FallbackParticleView';
import { useExitCountdown } from './use-exit-countdown'; import { useExitCountdown } from './use-exit-countdown';
import { StreamTopActions } from './StreamTopActions'; import { StreamTopActions } from './StreamTopActions';
import { StreamStatusPills } from './StreamStatusPills';
import { StreamActionsSheet, type StreamActionId } from './StreamActionsSheet'; import { StreamActionsSheet, type StreamActionId } from './StreamActionsSheet';
import { StreamMembersSheet } from './StreamMembersSheet'; import { StreamMembersSheet } from './StreamMembersSheet';
import { RenameStreamSheet } from './RenameStreamSheet'; import { RenameStreamSheet } from './RenameStreamSheet';
@@ -456,6 +460,37 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
contentFit={videoFit} contentFit={videoFit}
/> />
); );
case 'task':
return (
<TaskParticleView
key={particle.id}
particle={particle}
networkId={networkId}
streamId={streamParticle.id}
paused={paused}
onEnded={next}
onProgress={setProgress}
/>
);
case 'paper':
return (
<PaperParticleView
key={particle.id}
particle={particle}
paused={paused}
onEnded={next}
onProgress={setProgress}
/>
);
case 'file':
return (
<FileParticleView
key={particle.id}
particle={particle}
paused={paused}
onEnded={next}
/>
);
default: default:
return ( return (
<FallbackParticleView <FallbackParticleView
@@ -544,31 +579,6 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
/> />
</View> </View>
</View> </View>
{/* Top status pills: paused + exit countdown. Anchored just below
the metadata row (avatar + name 40px tall, starts at
insets.top + 32) so they share the top chrome real estate
instead of competing with captions at the bottom. */}
<View
pointerEvents="none"
className="absolute inset-x-0 items-center"
style={{ top: insets.top + 88 }}
>
{paused ? (
<View className="bg-white/15 rounded-full px-3 py-1">
<Text className="text-white/90 text-xs font-medium">
Paused
</Text>
</View>
) : null}
{exitRemainingMs !== null ? (
<View className="bg-white/15 rounded-full px-3 py-1 mt-2">
<Text className="text-white/90 text-xs font-medium">
Closing in {Math.ceil(exitRemainingMs / 1000)}s
</Text>
</View>
) : null}
</View>
</View> </View>
</GestureDetector> </GestureDetector>
@@ -624,6 +634,20 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
</View> </View>
</View> </View>
{/* Playback status pills pinned to the right chrome margin just
below the top actions row so they live in the same band as the
chrome buttons instead of overlaying the particle canvas. */}
<View
pointerEvents="none"
className="absolute right-3"
style={{ top: insets.top + 72 }}
>
<StreamStatusPills
paused={paused}
exitRemainingMs={exitRemainingMs}
/>
</View>
{/* Right-edge reaction stack mirrors desktop's ReactionBar. Vertically {/* Right-edge reaction stack mirrors desktop's ReactionBar. Vertically
centered on the canvas; outside the GestureDetector so each pill centered on the canvas; outside the GestureDetector so each pill
tap toggles cleanly without competing with the stream advance/back tap toggles cleanly without competing with the stream advance/back
@@ -0,0 +1,435 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Keyboard,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import { deleteField } from 'firebase/firestore';
import { Check, Plus, X } from 'lucide-react-native';
import type { ChecklistItem, Particle } from '@/api/types';
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
import {
updateParticle,
updateParticleProperties,
} from '@/lib/firestore-particles';
import { useNetwork } from '@/hooks/use-networks';
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { resolveHumanDisplay } from '@/lib/humans';
import { Avatar } from '@/components/Avatar';
import { cn } from '@/lib/utils';
import { useStreamSafeArea } from './stream-safe-area';
type TaskParticle = Extract<Particle, { type: 'task' }>;
interface TaskParticleViewProps {
particle: TaskParticle;
networkId: string;
streamId: string;
paused: boolean;
onEnded: () => void;
onProgress: (ratio: number) => void;
}
const DWELL_DURATION_S = 8;
const TICK_MS = 100;
/**
* Editable task card. Mirrors desktop's task-particle-view round done
* checkbox + title, notes, a checklist, and an assignee picker persisting
* each edit straight to Firestore. A fixed 8s dwell auto-advances the stream;
* focusing any field suspends playback so typing isn't raced by the timer.
*/
export function TaskParticleView({
particle,
networkId,
streamId,
paused,
onEnded,
onProgress,
}: TaskParticleViewProps) {
const network = useNetwork(networkId);
const safe = useStreamSafeArea();
const docPath = toFirestoreDocPath(
particlePath(networkId, [streamId, particle.id]),
);
const {
title,
notes,
checklist = [],
assigned_to,
done,
} = particle.properties;
// Suspend the dwell timer whenever a field is focused so typing isn't
// interrupted by an auto-advance. Mirrors desktop's `editing` suspender.
const [editing, setEditing] = useState(false);
useSuspendPlayback(editing, `task-edit-${particle.id}`);
// --- Fixed dwell (mirrors TextParticleView's interval cadence) ---
const elapsedRef = useRef(0);
useEffect(() => {
elapsedRef.current = 0;
onProgress(0);
}, [particle.id, onProgress]);
useEffect(() => {
if (paused) return;
const interval = setInterval(() => {
elapsedRef.current += TICK_MS / 1000;
const ratio = Math.min(elapsedRef.current / DWELL_DURATION_S, 1);
onProgress(ratio);
if (ratio >= 1) {
clearInterval(interval);
onEnded();
}
}, TICK_MS);
return () => clearInterval(interval);
}, [paused, onEnded, onProgress, particle.id]);
// Checklist writes replace the whole array; concurrent edits are
// last-write-wins (same tradeoff desktop documents). Keep a ref so a second
// edit composes on the latest local base before the next snapshot arrives.
const checklistRef = useRef(checklist);
useEffect(() => {
checklistRef.current = checklist;
}, [checklist]);
const writeChecklist = useCallback(
(items: ChecklistItem[]) => {
checklistRef.current = items;
return updateParticleProperties<'task'>(docPath, { checklist: items });
},
[docPath],
);
const handleToggleDone = useCallback(
() => updateParticleProperties<'task'>(docPath, { done: !done }),
[docPath, done],
);
const handleToggleItem = useCallback(
(index: number) => {
const items = checklistRef.current.map((item, i) =>
i === index ? { ...item, done: !item.done } : item,
);
void writeChecklist(items);
},
[writeChecklist],
);
const handleCommitItemText = useCallback(
(index: number, text: string) => {
const items = checklistRef.current.map((item, i) =>
i === index ? { ...item, text } : item,
);
void writeChecklist(items);
},
[writeChecklist],
);
const handleRemoveItem = useCallback(
(index: number) => {
void writeChecklist(checklistRef.current.filter((_, i) => i !== index));
},
[writeChecklist],
);
const handleAddItem = useCallback(
(text: string) => {
void writeChecklist([...checklistRef.current, { text, done: false }]);
},
[writeChecklist],
);
const handleAssign = useCallback(
(humanId: string | null) => {
if (!humanId) {
void updateParticle(docPath, 'properties.assigned_to', deleteField());
} else {
void updateParticleProperties<'task'>(docPath, {
assigned_to: humanId,
});
}
},
[docPath],
);
const doneCount = checklist.filter((item) => item.done).length;
return (
<View
className="flex-1 items-center justify-center px-6"
style={{ paddingTop: safe.top + 16, paddingBottom: safe.bottom + 16 }}
>
<View className="w-full max-w-xl" style={{ maxHeight: '100%' }}>
<ScrollView
className="max-h-full rounded-2xl bg-white/10"
contentContainerClassName="px-5 py-5 gap-5"
showsVerticalScrollIndicator
indicatorStyle="white"
keyboardShouldPersistTaps="handled"
// Dragging the card dismisses the keyboard (interactive follow on
// iOS; on-drag on Android, which lacks the interactive variant).
keyboardDismissMode={
Platform.OS === 'ios' ? 'interactive' : 'on-drag'
}
>
{/* Title + done */}
<View
className={cn('flex-row items-start gap-3', editing && 'pr-12')}
>
<Pressable
onPress={handleToggleDone}
hitSlop={8}
accessibilityLabel={done ? 'Mark not done' : 'Mark done'}
className={cn(
'mt-1 h-6 w-6 items-center justify-center rounded-full border',
done ? 'bg-emerald-500 border-emerald-500' : 'border-white/40',
)}
>
{done ? <Check color="white" size={14} strokeWidth={3} /> : null}
</Pressable>
<TextInput
defaultValue={title}
key={`title-${particle.id}`}
onFocus={() => setEditing(true)}
onBlur={() => setEditing(false)}
onEndEditing={(e) => {
const value = e.nativeEvent.text;
if (value !== title) {
void updateParticleProperties<'task'>(docPath, {
title: value,
});
}
}}
placeholder="Task title"
placeholderTextColor="rgba(255,255,255,0.3)"
multiline
className={cn(
'flex-1 text-white text-2xl font-semibold',
done && 'text-white/50 line-through',
)}
/>
</View>
{/* Notes */}
<TextInput
defaultValue={notes ?? ''}
key={`notes-${particle.id}`}
onFocus={() => setEditing(true)}
onBlur={() => setEditing(false)}
onEndEditing={(e) => {
const value = e.nativeEvent.text;
if (value !== (notes ?? '')) {
void updateParticleProperties<'task'>(docPath, {
notes: value,
});
}
}}
placeholder="Add notes…"
placeholderTextColor="rgba(255,255,255,0.3)"
multiline
className="text-white/80 text-base"
/>
{/* Checklist */}
<View className="gap-2">
{checklist.length > 0 ? (
<Text className="text-white/40 text-xs">
{doneCount} / {checklist.length} done
</Text>
) : null}
{checklist.map((item, index) => (
<ChecklistItemRow
key={`${particle.id}-item-${index}`}
item={item}
onToggle={() => handleToggleItem(index)}
onCommitText={(text) => handleCommitItemText(index, text)}
onRemove={() => handleRemoveItem(index)}
onFocusChange={setEditing}
/>
))}
<AddChecklistItemRow
onAdd={handleAddItem}
onFocusChange={setEditing}
/>
</View>
{/* Assignee */}
<View className="gap-2">
<Text className="text-white/40 text-xs">Assignee</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerClassName="gap-2"
>
<AssigneeChip
label="Unassigned"
selected={!assigned_to}
onPress={() => handleAssign(null)}
/>
{network?.humans?.map((human) => {
const display = resolveHumanDisplay(human.id, network?.humans);
return (
<AssigneeChip
key={human.id}
label={display.displayName}
selected={assigned_to === human.id}
onPress={() => handleAssign(human.id)}
avatarHumanId={human.id}
humans={network?.humans}
/>
);
})}
</ScrollView>
</View>
</ScrollView>
{/* While a field is focused the keyboard hides the stream's tap-zones,
so offer an explicit way out. Dismissing blurs the active field,
which flips `editing` off and resumes the dwell + tap navigation. */}
{editing ? (
<View className="absolute right-2 top-2">
<Pressable
onPress={() => Keyboard.dismiss()}
hitSlop={8}
accessibilityLabel="Done editing"
className="rounded-full bg-white/15 px-3 py-1.5"
>
<Text className="text-white text-sm font-medium">Done</Text>
</Pressable>
</View>
) : null}
</View>
</View>
);
}
function ChecklistItemRow({
item,
onToggle,
onCommitText,
onRemove,
onFocusChange,
}: {
item: ChecklistItem;
onToggle: () => void;
onCommitText: (text: string) => void;
onRemove: () => void;
onFocusChange: (focused: boolean) => void;
}) {
return (
<View className="flex-row items-center gap-2.5">
<Pressable
onPress={onToggle}
hitSlop={6}
accessibilityLabel={
item.done ? 'Mark subtask not done' : 'Mark subtask done'
}
className={cn(
'h-5 w-5 items-center justify-center rounded border',
item.done ? 'bg-emerald-500 border-emerald-500' : 'border-white/30',
)}
>
{item.done ? <Check color="white" size={12} strokeWidth={3} /> : null}
</Pressable>
<TextInput
defaultValue={item.text}
onFocus={() => onFocusChange(true)}
onBlur={() => onFocusChange(false)}
onEndEditing={(e) => {
const value = e.nativeEvent.text;
if (value !== item.text) onCommitText(value);
}}
placeholder="Subtask"
placeholderTextColor="rgba(255,255,255,0.3)"
className={cn(
'flex-1 text-white/90 text-sm',
item.done && 'text-white/40 line-through',
)}
/>
<Pressable
onPress={onRemove}
hitSlop={6}
accessibilityLabel="Remove subtask"
>
<X color="rgba(255,255,255,0.4)" size={14} />
</Pressable>
</View>
);
}
function AddChecklistItemRow({
onAdd,
onFocusChange,
}: {
onAdd: (text: string) => void;
onFocusChange: (focused: boolean) => void;
}) {
const [draft, setDraft] = useState('');
const submit = () => {
const text = draft.trim();
if (!text) return;
onAdd(text);
setDraft('');
};
return (
<View className="flex-row items-center gap-2.5">
<Plus color="rgba(255,255,255,0.3)" size={16} />
<TextInput
value={draft}
onChangeText={setDraft}
onFocus={() => onFocusChange(true)}
onBlur={() => onFocusChange(false)}
onSubmitEditing={submit}
blurOnSubmit={false}
placeholder="Add subtask…"
placeholderTextColor="rgba(255,255,255,0.3)"
className="flex-1 text-white/70 text-sm"
/>
</View>
);
}
function AssigneeChip({
label,
selected,
onPress,
avatarHumanId,
humans,
}: {
label: string;
selected: boolean;
onPress: () => void;
avatarHumanId?: string;
humans?: import('@/api/types').Human[];
}) {
return (
<Pressable
onPress={onPress}
className={cn(
'flex-row items-center gap-2 rounded-full px-3 py-2',
selected ? 'bg-white' : 'bg-white/10',
)}
>
{avatarHumanId ? (
<Avatar humanId={avatarHumanId} humans={humans} size="xs" />
) : null}
<Text
className={cn(
'text-sm font-medium',
selected ? 'text-black' : 'text-white/80',
)}
>
{label}
</Text>
</Pressable>
);
}
@@ -1,8 +1,8 @@
import { useEffect, useRef, type ReactNode } from 'react'; import { useEffect, useRef } from 'react';
import { Platform, ScrollView, Text, View, type ViewStyle } from 'react-native'; import { ScrollView, Text, View } from 'react-native';
import { Renderer, useMarkdown, type MarkedStyles } from 'react-native-marked';
import type { Particle } from '@/api/types'; import type { Particle } from '@/api/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { MarkdownBody } from '@/components/MarkdownBody';
import { RelativeTimestamp } from '@/components/RelativeTimestamp'; import { RelativeTimestamp } from '@/components/RelativeTimestamp';
import { useStreamSafeArea } from './stream-safe-area'; import { useStreamSafeArea } from './stream-safe-area';
@@ -47,156 +47,6 @@ function hasMarkdownFormatting(content: string): boolean {
); );
} }
// react-native-marked doesn't render GFM task-list checkboxes (marked strips
// the `[ ]`/`[x]` into token flags the parser ignores), so a write/read drift
// shows up as bullets with no box. Swap the marker for a checkbox glyph before
// parsing — read-only, matching desktop's bullet-free checkboxes.
const TASK_ITEM_RE = /^(\s*)[-*+] \[([ xX])\] /gm;
function withTaskCheckboxes(markdown: string): string {
return markdown.replace(
TASK_ITEM_RE,
(_match, indent: string, mark: string) =>
`${indent}${mark === ' ' ? '☐' : '☑'} `,
);
}
// Mirror the desktop Crepe palette (markdown-editor.css `--crepe-*`) so a
// message reads the same on both surfaces: white-on-transparent text, a blue
// accent, pink inline code, and a near-opaque dark surface behind code blocks
// and tables. Defined at module scope so the references stay stable —
// `useMarkdown` re-parses only when these or the content change.
//
// Known gap vs desktop: fenced code blocks aren't syntax-highlighted (Crepe
// uses CodeMirror; react-native-marked only exposes the language tag). They
// render as plain monospace on the dark surface, which is acceptable for v1.
const TEXT_COLOR = 'rgba(255,255,255,0.92)';
const ACCENT = '#60a5fa';
const SURFACE = 'rgba(24,24,28,0.96)';
const OUTLINE = 'rgba(255,255,255,0.2)';
const MONO = Platform.OS === 'ios' ? 'Menlo' : 'monospace';
const MARKDOWN_THEME = {
colors: {
text: TEXT_COLOR,
link: ACCENT,
code: SURFACE,
border: OUTLINE,
},
};
const MARKDOWN_STYLES: MarkedStyles = {
text: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
li: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
strong: { fontWeight: '700' },
em: { fontStyle: 'italic' },
strikethrough: {
textDecorationLine: 'line-through',
color: 'rgba(255,255,255,0.6)',
},
// fontStyle "normal" cancels react-native-marked's italic-by-default for
// links and inline code (desktop renders neither italic).
link: { color: ACCENT, fontStyle: 'normal' },
// borderBottomWidth 0 removes the library's default heading underline rule,
// which desktop's headings don't have.
h1: {
color: '#ffffff',
fontSize: 28,
lineHeight: 34,
fontWeight: '700',
marginTop: 8,
marginBottom: 8,
borderBottomWidth: 0,
},
h2: {
color: '#ffffff',
fontSize: 24,
lineHeight: 30,
fontWeight: '700',
marginTop: 8,
marginBottom: 6,
borderBottomWidth: 0,
},
h3: {
color: '#ffffff',
fontSize: 20,
lineHeight: 26,
fontWeight: '600',
marginTop: 6,
marginBottom: 4,
},
h4: {
color: '#ffffff',
fontSize: 18,
lineHeight: 24,
fontWeight: '600',
marginTop: 6,
marginBottom: 4,
},
h5: {
color: '#ffffff',
fontSize: 16,
lineHeight: 22,
fontWeight: '600',
marginTop: 4,
marginBottom: 2,
},
h6: {
color: 'rgba(255,255,255,0.7)',
fontSize: 15,
lineHeight: 20,
fontWeight: '600',
marginTop: 4,
marginBottom: 2,
},
codespan: {
color: '#fca5a5',
fontFamily: MONO,
fontStyle: 'normal',
backgroundColor: 'rgba(255,255,255,0.1)',
},
code: {
backgroundColor: SURFACE,
borderColor: OUTLINE,
borderWidth: 1,
borderRadius: 8,
padding: 12,
marginVertical: 6,
},
blockquote: {
borderLeftWidth: 3,
borderLeftColor: OUTLINE,
paddingLeft: 12,
marginVertical: 6,
opacity: 0.85,
},
// hr is left to the library default, which already draws a 1px rule in the
// themed border color (OUTLINE).
table: { borderWidth: 1, borderColor: OUTLINE, marginVertical: 6 },
tableRow: { borderColor: OUTLINE },
tableCell: { borderColor: OUTLINE, padding: 8 },
};
// react-native-marked feeds fenced code blocks the `em` (italic, proportional)
// text style, so out of the box code renders italic in the body font. Override
// `code` to apply a monospace, non-italic style instead — matching desktop's
// code blocks. Instantiated once at module scope to keep the reference stable
// for `useMarkdown`'s memoization.
const CODE_TEXT_STYLE = {
color: TEXT_COLOR,
fontFamily: MONO,
fontSize: 15,
lineHeight: 22,
};
class MarkdownRenderer extends Renderer {
code(text: string, language?: string, containerStyle?: ViewStyle): ReactNode {
return super.code(text, language, containerStyle, CODE_TEXT_STYLE);
}
}
const MARKDOWN_RENDERER = new MarkdownRenderer();
export function TextParticleView({ export function TextParticleView({
particle, particle,
paused, paused,
@@ -208,11 +58,6 @@ export function TextParticleView({
const durationS = computeReadDuration(content); const durationS = computeReadDuration(content);
const elapsedRef = useRef(0); const elapsedRef = useRef(0);
const safe = useStreamSafeArea(); const safe = useStreamSafeArea();
const markdownNodes = useMarkdown(withTaskCheckboxes(content), {
renderer: MARKDOWN_RENDERER,
theme: MARKDOWN_THEME,
styles: MARKDOWN_STYLES,
});
const editedLabel = editedAt ? ( const editedLabel = editedAt ? (
<View className="mt-3 items-center"> <View className="mt-3 items-center">
@@ -290,7 +135,7 @@ export function TextParticleView({
showsVerticalScrollIndicator showsVerticalScrollIndicator
indicatorStyle="white" indicatorStyle="white"
> >
{markdownNodes} <MarkdownBody content={content} />
{editedLabel} {editedLabel}
</ScrollView> </ScrollView>
</View> </View>
@@ -189,6 +189,7 @@ export function NewStreamScreen({
networkId={networkId} networkId={networkId}
targetPath={placeholderPath} targetPath={placeholderPath}
silentPresence silentPresence
allowTask={false}
submitMedia={submitMedia} submitMedia={submitMedia}
submitText={submitText} submitText={submitText}
/> />
+17 -39
View File
@@ -3,11 +3,12 @@ import { Pressable, Text, View } from 'react-native';
import { Headphones } from 'lucide-react-native'; import { Headphones } from 'lucide-react-native';
import type { Particle, StreamProperties } from '@/api/types'; import type { Particle, StreamProperties } from '@/api/types';
import { isParticleDeleted } from '@/api/types'; import { isParticleDeleted } from '@/api/types';
import { Avatar } from '@/components/Avatar';
import { RelativeTimestamp } from '@/components/RelativeTimestamp'; import { RelativeTimestamp } from '@/components/RelativeTimestamp';
import { useLiveLatestChild } from '@/hooks/use-particle'; import { useLiveLatestChild } from '@/hooks/use-particle';
import { useNetwork } from '@/hooks/use-networks'; import { useNetwork } from '@/hooks/use-networks';
import { particlePath } from '@/lib/particle-path'; import { particlePath } from '@/lib/particle-path';
import { cn, getInitials } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
interface StreamCardProps { interface StreamCardProps {
@@ -35,34 +36,20 @@ export const StreamCard = memo(function StreamCard({
particle.visible_to.length === 2 && particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith('human:')); particle.visible_to.every((v) => v.startsWith('human:'));
const initials = useMemo(() => { // The human represented by the card: the other party in a DM, otherwise the
// author of the latest message. Group streams with no messages resolve to
// null and fall back to the stream-name initials below.
const avatarHumanId = useMemo(() => {
if (isDM) { if (isDM) {
const otherEntry = particle.visible_to.find( const otherEntry = particle.visible_to.find(
(v) => v !== `human:${userId}`, (v) => v !== `human:${userId}`,
); );
if (otherEntry) { if (otherEntry) return otherEntry.replace('human:', '');
const otherId = otherEntry.replace('human:', '');
const otherHuman = network?.humans?.find((h) => h.id === otherId);
if (otherHuman) return getInitials(otherHuman.email);
}
} }
return latestChild?.created_by_human_id ?? null;
}, [isDM, particle.visible_to, userId, latestChild]);
if (latestChild) { const fallbackInitials = particle.properties.name.slice(0, 2).toUpperCase();
const creator = network?.humans?.find(
(h) => h.id === latestChild.created_by_human_id,
);
if (creator) return getInitials(creator.email);
}
return particle.properties.name.slice(0, 2).toUpperCase();
}, [
isDM,
particle.visible_to,
particle.properties.name,
userId,
latestChild,
network,
]);
const isUnseen = useMemo(() => { const isUnseen = useMemo(() => {
if (!latestChild) return false; if (!latestChild) return false;
@@ -87,7 +74,7 @@ export const StreamCard = memo(function StreamCard({
return latestChild.properties.content; return latestChild.properties.content;
case 'file': case 'file':
return latestChild.properties.filename; return latestChild.properties.filename;
case 'quest': case 'task':
return latestChild.properties.title; return latestChild.properties.title;
case 'paper': case 'paper':
return latestChild.properties.title; return latestChild.properties.title;
@@ -102,21 +89,12 @@ export const StreamCard = memo(function StreamCard({
android_ripple={{ color: 'rgba(0,0,0,0.05)' }} android_ripple={{ color: 'rgba(0,0,0,0.05)' }}
className="bg-card flex-row items-center gap-3 px-4 py-3 active:bg-accent" className="bg-card flex-row items-center gap-3 px-4 py-3 active:bg-accent"
> >
<View <Avatar
className={cn( humanId={avatarHumanId}
'h-10 w-10 items-center justify-center rounded-full', humans={network?.humans}
isUnseen ? 'bg-primary' : 'bg-muted', size="md"
)} fallbackInitials={fallbackInitials}
> />
<Text
className={cn(
'text-xs font-semibold',
isUnseen ? 'text-primary-foreground' : 'text-muted-foreground',
)}
>
{initials}
</Text>
</View>
<View className="flex-1"> <View className="flex-1">
<Text <Text
@@ -6,6 +6,7 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { Settings as SettingsIcon } from 'lucide-react-native';
import { ListSeparator } from '@/components/ListSeparator'; import { ListSeparator } from '@/components/ListSeparator';
import { toUserMessage } from '@/lib/errors'; import { toUserMessage } from '@/lib/errors';
import { particlePath } from '@/lib/particle-path'; import { particlePath } from '@/lib/particle-path';
@@ -32,6 +33,9 @@ export function StreamListScreen({
<Header <Header
title={network?.name ?? 'Streams'} title={network?.name ?? 'Streams'}
onBack={() => navigation.goBack()} onBack={() => navigation.goBack()}
onOpenSettings={() =>
navigation.navigate('NetworkSettings', { networkId })
}
/> />
{error ? ( {error ? (
@@ -67,7 +71,15 @@ export function StreamListScreen({
); );
} }
function Header({ title, onBack }: { title: string; onBack: () => void }) { function Header({
title,
onBack,
onOpenSettings,
}: {
title: string;
onBack: () => void;
onOpenSettings: () => void;
}) {
return ( return (
<View className="flex-row items-center px-3 py-3 border-b border-border"> <View className="flex-row items-center px-3 py-3 border-b border-border">
<Pressable <Pressable
@@ -83,7 +95,14 @@ function Header({ title, onBack }: { title: string; onBack: () => void }) {
> >
{title} {title}
</Text> </Text>
<View className="w-8" /> <Pressable
onPress={onOpenSettings}
className="px-2 py-1"
accessibilityLabel="Network settings"
hitSlop={8}
>
<SettingsIcon size={20} color="#fafafa" />
</Pressable>
</View> </View>
); );
} }
+20
View File
@@ -0,0 +1,20 @@
import { skipToken, useQuery } from '@tanstack/react-query';
import { apiClient } from '@/api/client';
/**
* Resolve an avatar object id to a signed download URL. React Query handles
* caching and de-duping, so many avatars sharing an id make a single request.
* Mirrors desktop's use-avatar-url.
*/
export function useAvatarUrl(
objectId: string | null | undefined,
): string | undefined {
const { data } = useQuery({
queryKey: ['avatar-url', objectId],
queryFn: objectId
? () => apiClient.getAvatarDownloadUrl(objectId)
: skipToken,
staleTime: 1000 * 60 * 60, // 1 hour — signed URLs valid for 24h
});
return data;
}
+33
View File
@@ -0,0 +1,33 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { apiClient } from '@/api/client';
import type { BillingCadence } from '@/api/types';
/** Plan + quota summary. Member-accessible (sourced from `/usage`). */
export function useNetworkUsage(networkId: string) {
return useQuery({
queryKey: ['network-usage', networkId],
queryFn: () => apiClient.getNetworkUsage(networkId),
});
}
/** Full billing status. Admin-gated (`/billing`). */
export function useNetworkBilling(networkId: string, enabled: boolean) {
return useQuery({
queryKey: ['network-billing', networkId],
queryFn: () => apiClient.getNetworkBilling(networkId),
enabled,
});
}
export function useCreateCheckoutSession(networkId: string) {
return useMutation({
mutationFn: (cadence: BillingCadence) =>
apiClient.createCheckoutSession(networkId, cadence),
});
}
export function useCreatePortalSession(networkId: string) {
return useMutation({
mutationFn: () => apiClient.createPortalSession(networkId),
});
}
+39
View File
@@ -0,0 +1,39 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/api/client';
import type { CreateNetworkRequest } from '@/api/types';
/** Invitations addressed to the signed-in user's email. */
export function useMyInvitations() {
return useQuery({
queryKey: ['invitations'],
queryFn: () => apiClient.listMyInvitations(),
meta: { toastOnError: true },
});
}
/**
* Accept a pending invitation, then refresh both the networks list (the user
* is now a member) and the invitations list (the invite is consumed).
*/
export function useAcceptInvitation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (networkId: string) =>
apiClient.acceptInvitation({ network_id: networkId }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['networks'] });
void queryClient.invalidateQueries({ queryKey: ['invitations'] });
},
});
}
/** Create a network; the creator becomes its admin and first member. */
export function useCreateNetwork() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateNetworkRequest) => apiClient.createNetwork(data),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['networks'] });
},
});
}
@@ -0,0 +1,53 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/api/client';
/** Pending invitations sent for a network (member-visible). */
export function useNetworkInvitations(networkId: string) {
return useQuery({
queryKey: ['network-invitations', networkId],
queryFn: () => apiClient.listNetworkInvitations(networkId),
});
}
/**
* Invite people by email. Existing users join directly; others get a pending
* invitation. Refreshes both the network (new members) and its invitation list.
*/
export function useAddMembers(networkId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (emails: string[]) =>
apiClient.addMembers(networkId, { email_addresses: emails }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['networks'] });
void queryClient.invalidateQueries({
queryKey: ['network-invitations', networkId],
});
},
});
}
/** Remove a member from the network (admin only). */
export function useRemoveMember(networkId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (humanId: string) => apiClient.removeMember(networkId, humanId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['networks'] });
},
});
}
/** Revoke a pending invitation by email. */
export function useRevokeInvitation(networkId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (email: string) =>
apiClient.revokeInvitation(networkId, { email }),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ['network-invitations', networkId],
});
},
});
}
+1 -1
View File
@@ -97,7 +97,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
case 'media': case 'media':
case 'file': case 'file':
case 'text': case 'text':
case 'quest': case 'task':
case 'paper': { case 'paper': {
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text // Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
// particles carry `properties.edited_at`, so coerce it if present. // particles carry `properties.edited_at`, so coerce it if present.
+26
View File
@@ -103,6 +103,32 @@ export async function createTextParticle({
return createParticle(collectionPath, 'text', { content }, createdByHumanId); return createParticle(collectionPath, 'text', { content }, createdByHumanId);
} }
interface CreateTaskParticleParams {
targetPath: ParticlePath;
title: string;
notes?: string;
createdByHumanId: string;
}
/**
* Create a `task` particle. Mirrors createTextParticle the checklist and
* assignee are left empty and edited inline in the task card afterwards.
*/
export async function createTaskParticle({
targetPath,
title,
notes,
createdByHumanId,
}: CreateTaskParticleParams): Promise<string> {
const collectionPath = toFirestoreChildrenPath(targetPath);
return createParticle(
collectionPath,
'task',
{ title, done: false, ...(notes ? { notes } : {}) },
createdByHumanId,
);
}
function extensionFromMime(mime: string): string { function extensionFromMime(mime: string): string {
if (mime === 'video/mp4') return '.mp4'; if (mime === 'video/mp4') return '.mp4';
if (mime === 'video/quicktime') return '.mov'; if (mime === 'video/quicktime') return '.mov';
@@ -7,6 +7,7 @@ import { StreamListScreen } from '@/features/streams/StreamListScreen';
import { NewStreamScreen } from '@/features/streams/NewStreamScreen'; import { NewStreamScreen } from '@/features/streams/NewStreamScreen';
import { StreamViewScreen } from '@/features/stream-view/StreamViewScreen'; import { StreamViewScreen } from '@/features/stream-view/StreamViewScreen';
import { HuddleScreen } from '@/features/huddle/HuddleScreen'; import { HuddleScreen } from '@/features/huddle/HuddleScreen';
import { NetworkSettingsScreen } from '@/features/network-settings/NetworkSettingsScreen';
import { SettingsScreen } from '@/features/settings/SettingsScreen'; import { SettingsScreen } from '@/features/settings/SettingsScreen';
import { AccountScreen } from '@/features/settings/AccountScreen'; import { AccountScreen } from '@/features/settings/AccountScreen';
import type { RootStackParamList } from './types'; import type { RootStackParamList } from './types';
@@ -54,6 +55,11 @@ export function RootNavigator() {
component={NewStreamScreen} component={NewStreamScreen}
options={{ animation: 'slide_from_bottom' }} options={{ animation: 'slide_from_bottom' }}
/> />
<Stack.Screen
name="NetworkSettings"
component={NetworkSettingsScreen}
options={{ animation: 'slide_from_right' }}
/>
<Stack.Screen name="Settings" component={SettingsScreen} /> <Stack.Screen name="Settings" component={SettingsScreen} />
<Stack.Screen name="Account" component={AccountScreen} /> <Stack.Screen name="Account" component={AccountScreen} />
</Stack.Navigator> </Stack.Navigator>
+1
View File
@@ -15,6 +15,7 @@ export type RootStackParamList = {
serverUrl: string; serverUrl: string;
}; };
NewStream: { networkId: string }; NewStream: { networkId: string };
NetworkSettings: { networkId: string };
Settings: undefined; Settings: undefined;
Account: undefined; Account: undefined;
}; };
+11
View File
@@ -63,6 +63,8 @@ interface AuthState {
requestCode: (email: string) => Promise<void>; requestCode: (email: string) => Promise<void>;
signIn: (email: string, code: string) => Promise<void>; signIn: (email: string, code: string) => Promise<void>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
/** Re-fetch the current user from Orion (e.g. after an avatar change). */
refreshUser: () => Promise<void>;
/** /**
* Wipes the session in response to a server-detected auth failure (e.g. a * Wipes the session in response to a server-detected auth failure (e.g. a
* 401 surfaced through react-query). Does not call `/auth/sign-out`; the * 401 surfaced through react-query). Does not call `/auth/sign-out`; the
@@ -166,6 +168,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
} }
}, },
refreshUser: async () => {
try {
const user = await apiClient.me();
set({ user });
} catch (err) {
logError(err, { scope: 'auth.refreshUser' });
}
},
invalidateSession: async () => { invalidateSession: async () => {
stopPushTokenSync(); stopPushTokenSync();
apiClient.setToken(null); apiClient.setToken(null);
+12
View File
@@ -4278,6 +4278,18 @@ expo-haptics@~15.0.7:
resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.8.tgz#f93f895ac5d76fe0c5ac26b3644e1dbb097833f3" resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.8.tgz#f93f895ac5d76fe0c5ac26b3644e1dbb097833f3"
integrity sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g== integrity sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==
expo-image-loader@~6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-6.0.0.tgz#15230442cbb90e101c080a4c81e37d974e43e072"
integrity sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==
expo-image-picker@~17.0.8:
version "17.0.11"
resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.11.tgz#8f1537df946f7c19747ba9d0043ef7e3e8cb67dc"
integrity sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ==
dependencies:
expo-image-loader "~6.0.0"
expo-keep-awake@~15.0.8: expo-keep-awake@~15.0.8:
version "15.0.8" version "15.0.8"
resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz#911c5effeba9baff2ccde79ef0ff5bf856215f8d" resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz#911c5effeba9baff2ccde79ef0ff5bf856215f8d"