Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79824baaa6 | |||
| d60b948262 | |||
| fec7f5d6d8 | |||
| d14cec5885 | |||
| 3d80ac2993 | |||
| 428f9ea1c9 | |||
| f2f8602263 | |||
| 661577ecd2 | |||
| c6ce1bdc65 |
@@ -69,9 +69,9 @@ spec:
|
||||
name: shared-secrets
|
||||
key: STRIPE_WEBHOOK_SECRET
|
||||
- name: "STRIPE_PRICE_PRO_MONTHLY"
|
||||
value: "price_1TMBElJu6RWBXAm2pPthUoh6"
|
||||
value: "price_1ToweOJu6RWBXAm2w2wTo8VL"
|
||||
- name: "STRIPE_PRICE_PRO_ANNUAL"
|
||||
value: "price_1TMBElJu6RWBXAm2TOpzdXk5"
|
||||
value: "price_1TowexJu6RWBXAm2G9M0WJBK"
|
||||
- name: "BILLING_SUCCESS_URL"
|
||||
value: "llink://billing/success"
|
||||
- name: "BILLING_CANCEL_URL"
|
||||
|
||||
+17
-1
@@ -1,8 +1,24 @@
|
||||
<!doctype html>
|
||||
<html class="dark">
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Flowy.llink",
|
||||
"productName": "Flowy.llink",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"description": "Flowy.llink is a video messaging app for teams",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
|
||||
@@ -23,12 +23,18 @@ import {
|
||||
import { SoundEffectsProvider } from '@/lib/sound-effects/sound-effects-provider';
|
||||
import { platform } from '@/lib/platform';
|
||||
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 App = () => {
|
||||
const status = useAuthStore((s) => s.status);
|
||||
const restoreSession = useAuthStore((s) => s.restoreSession);
|
||||
const hasCompletedOnboarding = useOnboardingStore(
|
||||
(s) => s.hasCompletedOnboarding,
|
||||
);
|
||||
const markOnboardingComplete = useOnboardingStore((s) => s.markComplete);
|
||||
|
||||
useEffect(() => {
|
||||
restoreSession();
|
||||
@@ -46,6 +52,10 @@ const App = () => {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
if (!hasCompletedOnboarding) {
|
||||
return <OnboardingOverlay onComplete={markOnboardingComplete} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PusherProvider>
|
||||
<AuthenticatedApp />
|
||||
|
||||
@@ -29,14 +29,14 @@ export function ComposingIndicator({
|
||||
return (
|
||||
<div
|
||||
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="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:0ms]" />
|
||||
<span className="size-1 rounded-full bg-white/70 animate-bounce [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:0ms]" />
|
||||
<span className="bg-muted-foreground size-1 animate-bounce rounded-full [animation-delay:150ms]" />
|
||||
<span className="bg-muted-foreground size-1 animate-bounce rounded-full [animation-delay:300ms]" />
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-[10px] text-white/50">
|
||||
<span className="text-muted-foreground whitespace-nowrap text-[10px]">
|
||||
{displayName} {modeLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -38,23 +38,23 @@ export function ConfirmDestructiveOverlay({
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-scrim/60 backdrop-blur-sm"
|
||||
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">
|
||||
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
<KeyHint
|
||||
keys="Esc"
|
||||
onClick={onClose}
|
||||
title="Close (or press Esc)"
|
||||
className="text-xs text-white/30"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
to close
|
||||
</KeyHint>
|
||||
</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">
|
||||
<Button
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Fragment } from 'react';
|
||||
import { Kbd } from '@/components/ui/kbd';
|
||||
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 =
|
||||
'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 {
|
||||
/** Key chip(s): "Esc" or ["Esc", "Q"]. */
|
||||
@@ -68,7 +69,7 @@ export function KeyHint({
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title={title}
|
||||
className={cn(
|
||||
'cursor-pointer rounded transition-colors hover:text-white/80',
|
||||
'cursor-pointer rounded transition-opacity hover:opacity-80',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
|
||||
@@ -48,19 +48,19 @@ export function KeybindingsOverlay({
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-scrim/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* 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">
|
||||
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
<KeyHint
|
||||
keys={['Esc', '?']}
|
||||
separator="or"
|
||||
onClick={onClose}
|
||||
title="Close (or press Esc / ?)"
|
||||
className="text-xs text-white/30"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
to close
|
||||
</KeyHint>
|
||||
@@ -68,21 +68,19 @@ export function KeybindingsOverlay({
|
||||
<div className="flex flex-col gap-5">
|
||||
{groups.map((group) => (
|
||||
<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}
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{group.bindings.map((binding) => (
|
||||
<li
|
||||
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">
|
||||
{binding.description}
|
||||
</span>
|
||||
<span className="text-sm">{binding.description}</span>
|
||||
<KeyHint
|
||||
keys={binding.keys}
|
||||
className="flex items-center gap-1 text-white/60"
|
||||
className="text-muted-foreground flex items-center gap-1"
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
||||
|
||||
return (
|
||||
<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) => {
|
||||
e.stopPropagation();
|
||||
handleOpen(e);
|
||||
@@ -39,7 +39,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
||||
/>
|
||||
)}
|
||||
<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 ? (
|
||||
<img
|
||||
src={metadata.favicon}
|
||||
@@ -57,12 +57,12 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
||||
<span className="truncate">{metadata.domain}</span>
|
||||
</div>
|
||||
{metadata.title && (
|
||||
<p className="truncate text-sm font-semibold leading-snug text-white">
|
||||
<p className="truncate text-sm font-semibold leading-snug">
|
||||
{metadata.title}
|
||||
</p>
|
||||
)}
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
@@ -71,7 +71,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-white/70 hover:bg-white/10 hover:text-white"
|
||||
className="text-muted-foreground"
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<ExternalLink data-icon="inline-start" />
|
||||
@@ -80,7 +80,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-white/70 hover:bg-white/10 hover:text-white"
|
||||
className="text-muted-foreground"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
@@ -111,12 +111,12 @@ export function LinkPreviewCardFallback({ url }: { url: string }) {
|
||||
|
||||
export function LinkPreviewCardSkeleton() {
|
||||
return (
|
||||
<div className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md">
|
||||
<Skeleton className="h-32 w-full rounded-none bg-white/5" />
|
||||
<div className="bg-card border-border max-w-sm overflow-hidden rounded-2xl border">
|
||||
<Skeleton className="h-32 w-full rounded-none" />
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<Skeleton className="h-3 w-24 bg-white/10" />
|
||||
<Skeleton className="h-4 w-48 bg-white/10" />
|
||||
<Skeleton className="h-3 w-full bg-white/10" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,13 +34,13 @@ export function ScreenSourcePicker({
|
||||
const windows = sources.filter((s) => s.id.startsWith('window:'));
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
|
||||
<div className="mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-zinc-900 shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
|
||||
<h2 className="text-base font-medium text-zinc-100">{title}</h2>
|
||||
<div className="bg-scrim/80 absolute inset-0 z-50 flex items-center justify-center">
|
||||
<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="border-border flex items-center justify-between border-b px-5 py-4">
|
||||
<h2 className="text-base font-medium">{title}</h2>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-zinc-400 hover:text-zinc-200"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -48,7 +48,7 @@ export function ScreenSourcePicker({
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{loading ? (
|
||||
<p className="text-center text-sm text-zinc-400">
|
||||
<p className="text-muted-foreground text-center text-sm">
|
||||
Loading sources...
|
||||
</p>
|
||||
) : (
|
||||
@@ -73,17 +73,17 @@ export function ScreenSourcePicker({
|
||||
)}
|
||||
</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
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
disabled={!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}
|
||||
</button>
|
||||
@@ -106,7 +106,7 @@ function SourceSection({
|
||||
}) {
|
||||
return (
|
||||
<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}
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
@@ -116,8 +116,8 @@ function SourceSection({
|
||||
onClick={() => onSelect(source.id)}
|
||||
className={`overflow-hidden rounded-lg border-2 text-left transition-colors ${
|
||||
selectedId === source.id
|
||||
? 'border-blue-500 bg-zinc-800'
|
||||
: 'border-transparent bg-zinc-800/50 hover:border-zinc-600'
|
||||
? 'border-primary bg-accent'
|
||||
: 'border-transparent bg-muted hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
@@ -125,7 +125,7 @@ function SourceSection({
|
||||
alt={source.name}
|
||||
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}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
@@ -39,7 +39,7 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -47,7 +47,7 @@ function Slider({
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
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>
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
OctagonXIcon,
|
||||
Loader2Icon,
|
||||
} from 'lucide-react';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const isDark = useThemeStore((s) => s.isDark);
|
||||
return (
|
||||
<Sonner
|
||||
theme="dark"
|
||||
theme={isDark ? 'dark' : 'light'}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
|
||||
@@ -21,7 +21,7 @@ export function WindowControls() {
|
||||
size="icon-sm"
|
||||
onClick={() => platform.window.minimize()}
|
||||
aria-label="Minimize"
|
||||
className="dark:hover:bg-white/10 rounded text-white/50"
|
||||
className="text-muted-foreground rounded"
|
||||
>
|
||||
<Minus />
|
||||
</Button>
|
||||
@@ -34,7 +34,7 @@ export function WindowControls() {
|
||||
size="icon-sm"
|
||||
onClick={() => platform.window.maximize()}
|
||||
aria-label={isMaximized ? 'Restore' : 'Maximize'}
|
||||
className="dark:hover:bg-white/10 rounded text-white/50"
|
||||
className="text-muted-foreground rounded"
|
||||
>
|
||||
{isMaximized ? <Copy /> : <Square />}
|
||||
</Button>
|
||||
@@ -47,7 +47,7 @@ export function WindowControls() {
|
||||
size="icon-sm"
|
||||
onClick={() => platform.window.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 />
|
||||
</Button>
|
||||
|
||||
@@ -172,7 +172,7 @@ export function AttachmentLightbox({
|
||||
}}
|
||||
>
|
||||
<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
|
||||
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"
|
||||
|
||||
@@ -62,7 +62,7 @@ function AttachmentThumbnail({
|
||||
tabIndex={previewable ? 0 : undefined}
|
||||
onClick={previewable && onPreview ? onPreview : undefined}
|
||||
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',
|
||||
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">
|
||||
<FileIcon className="size-5 text-white/60" />
|
||||
<span className="max-w-full truncate text-[9px] text-white/50">
|
||||
<FileIcon className="text-muted-foreground size-5" />
|
||||
<span className="text-muted-foreground max-w-full truncate text-[9px]">
|
||||
{attachment.file.name}
|
||||
</span>
|
||||
<span className="text-[9px] text-white/40">
|
||||
<span className="text-muted-foreground text-[9px]">
|
||||
{formatFileSize(attachment.file.size)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<Loader2 className="size-4 animate-spin text-white/70" />
|
||||
<div className="bg-scrim/50 absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 className="size-4 animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -97,7 +97,7 @@ function AttachmentThumbnail({
|
||||
e.stopPropagation();
|
||||
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" />
|
||||
</button>
|
||||
@@ -108,9 +108,9 @@ function AttachmentThumbnail({
|
||||
function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
|
||||
if (entry.isLoading) {
|
||||
return (
|
||||
<div className="flex h-16 w-28 shrink-0 flex-col gap-1.5 rounded-lg bg-white/10 p-2">
|
||||
<Skeleton className="h-2 w-16 bg-white/10" />
|
||||
<Skeleton className="h-3 w-24 bg-white/10" />
|
||||
<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" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -124,9 +124,9 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
|
||||
<button
|
||||
type="button"
|
||||
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 ? (
|
||||
<img
|
||||
src={metadata.favicon}
|
||||
@@ -142,7 +142,7 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
|
||||
<span className="truncate">{domain}</span>
|
||||
</div>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
@@ -197,7 +197,7 @@ export function AttachmentStrip({
|
||||
e.stopPropagation();
|
||||
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" />
|
||||
</button>
|
||||
|
||||
@@ -29,7 +29,11 @@ import { useMediaDevices } from '@/hooks/use-media-devices';
|
||||
import { resolveEffectiveDeviceId } from '@/hooks/use-effective-device-id';
|
||||
import { useFileInput } from '@/hooks/use-file-input';
|
||||
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 { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||||
@@ -59,8 +63,6 @@ interface ComposeOverlayProps {
|
||||
onParticleCreated?: (particleId: string) => void;
|
||||
}
|
||||
|
||||
const HOLD_THRESHOLD_MS = 250;
|
||||
|
||||
/**
|
||||
* Self-contained compose overlay. Each consumer renders its own instance
|
||||
* with props that determine the mode (new stream vs. reply).
|
||||
@@ -595,6 +597,16 @@ export function ComposeOverlay({
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
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 (
|
||||
currentStep === 'typing' ||
|
||||
currentStep === 'task' ||
|
||||
@@ -602,7 +614,7 @@ export function ComposeOverlay({
|
||||
currentStep === 'picking'
|
||||
) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
consume();
|
||||
cancel();
|
||||
}
|
||||
return;
|
||||
@@ -620,19 +632,19 @@ export function ComposeOverlay({
|
||||
switch (currentStep) {
|
||||
case 'idle': {
|
||||
if (e.key === '`' && !e.repeat) {
|
||||
e.preventDefault();
|
||||
consume();
|
||||
handleRecordIntent();
|
||||
} else if (e.key === 's' || e.key === 'S') {
|
||||
e.preventDefault();
|
||||
consume();
|
||||
if (!guardIdle()) break;
|
||||
if (!requireDesktop('Screen recording')) break;
|
||||
setRecordingSource('screen');
|
||||
setStepSync('picking');
|
||||
} else if (e.key === 't' || e.key === 'T') {
|
||||
e.preventDefault();
|
||||
consume();
|
||||
handleTextIntent();
|
||||
} else if (e.key === 'd' || e.key === 'D') {
|
||||
e.preventDefault();
|
||||
consume();
|
||||
handleTaskIntent();
|
||||
}
|
||||
break;
|
||||
@@ -641,17 +653,17 @@ export function ComposeOverlay({
|
||||
case 'recording': {
|
||||
if (e.key === '`' && !e.repeat) {
|
||||
// Second tap stops media recording (toggle mode)
|
||||
e.preventDefault();
|
||||
consume();
|
||||
handleStopIntent();
|
||||
} else if (
|
||||
(e.key === 's' || e.key === 'S') &&
|
||||
recordingSourceRef.current === 'screen'
|
||||
) {
|
||||
// S stops screen recording when main window is focused
|
||||
e.preventDefault();
|
||||
consume();
|
||||
handleStopIntent();
|
||||
} else if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
consume();
|
||||
handleCancelIntent();
|
||||
}
|
||||
break;
|
||||
@@ -659,10 +671,10 @@ export function ComposeOverlay({
|
||||
|
||||
case 'reviewing': {
|
||||
if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
consume();
|
||||
handleCancelIntent();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
consume();
|
||||
handleSendIntent();
|
||||
}
|
||||
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);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, [
|
||||
@@ -755,7 +770,7 @@ export function ComposeOverlay({
|
||||
/>
|
||||
)}
|
||||
{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="flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
|
||||
@@ -827,8 +842,8 @@ export function ComposeOverlay({
|
||||
/>
|
||||
)}
|
||||
{step === 'submitting' && (
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
|
||||
<span className="animate-pulse text-sm text-white/60">
|
||||
<div className="bg-background/95 absolute inset-0 z-50 flex flex-col items-center justify-center backdrop-blur-sm">
|
||||
<span className="text-muted-foreground animate-pulse text-sm">
|
||||
Sending...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -77,13 +77,13 @@ export function ConfigureContainerStep({
|
||||
|
||||
return (
|
||||
<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}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
|
||||
<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'}
|
||||
</Label>
|
||||
<Input
|
||||
@@ -94,21 +94,22 @@ export function ConfigureContainerStep({
|
||||
setName(e.target.value);
|
||||
}}
|
||||
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>
|
||||
|
||||
{/* Visibility */}
|
||||
<div>
|
||||
<Label className="mb-1 text-xs text-white/50">Visible to</Label>
|
||||
<div className="rounded-md border border-white/10">
|
||||
<Label className="text-muted-foreground mb-1 text-xs">
|
||||
Visible to
|
||||
</Label>
|
||||
<div className="border-border rounded-md border">
|
||||
{/* Everyone in network */}
|
||||
<div
|
||||
role="button"
|
||||
onClick={() => setEveryone((prev) => !prev)}
|
||||
className={cn(
|
||||
'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
|
||||
@@ -137,7 +138,7 @@ export function ConfigureContainerStep({
|
||||
onClick={() => toggleMember(member.id)}
|
||||
className={cn(
|
||||
'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
|
||||
@@ -145,7 +146,7 @@ export function ConfigureContainerStep({
|
||||
tabIndex={-1}
|
||||
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}
|
||||
</span>
|
||||
<span className="flex-1 truncate">
|
||||
@@ -162,7 +163,7 @@ export function ConfigureContainerStep({
|
||||
</div>
|
||||
|
||||
{/* 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)">
|
||||
cancel
|
||||
</KeyHint>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/* Theme Milkdown Crepe to blend into the app's translucent "glass" surfaces.
|
||||
* Crepe's frame-dark theme is a grayscale palette driven by --crepe-* custom
|
||||
* properties; we override those to white-on-transparent with a blue accent so
|
||||
* the editor (and the read-only display, which uses the same engine) sits on
|
||||
/* Theme Milkdown Crepe to the app's design tokens so it follows light/dark.
|
||||
* Crepe's theme is a palette driven by --crepe-* custom properties; we map
|
||||
* those onto our CSS variables (globals.css) so the editor (and the read-only
|
||||
* 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.
|
||||
*
|
||||
* The floating menus (slash menu, selection toolbar, link tooltip) are portaled
|
||||
* to <body>, OUTSIDE .milkdown — so the variables must be declared on those
|
||||
* 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,
|
||||
.milkdown-slash-menu,
|
||||
.milkdown-toolbar,
|
||||
@@ -15,24 +16,30 @@
|
||||
.milkdown-link-preview,
|
||||
.milkdown-block-handle {
|
||||
--crepe-color-background: transparent;
|
||||
--crepe-color-on-background: rgb(255 255 255 / 0.92);
|
||||
/* Surface backs code blocks, tables, and the floating menus — keep it
|
||||
* (near-)opaque so menus read clearly over content. */
|
||||
--crepe-color-surface: rgb(24 24 28 / 0.96);
|
||||
--crepe-color-surface-low: rgb(42 42 50 / 0.96);
|
||||
--crepe-color-on-surface: #ffffff;
|
||||
--crepe-color-on-surface-variant: rgb(255 255 255 / 0.65);
|
||||
--crepe-color-outline: rgb(255 255 255 / 0.2);
|
||||
--crepe-color-primary: #60a5fa;
|
||||
--crepe-color-secondary: rgb(96 165 250 / 0.25);
|
||||
--crepe-color-on-secondary: #ffffff;
|
||||
--crepe-color-inverse: #ffffff;
|
||||
--crepe-color-on-inverse: #0b0b0b;
|
||||
--crepe-color-inline-code: #fca5a5;
|
||||
--crepe-color-inline-area: rgb(255 255 255 / 0.1);
|
||||
--crepe-color-error: #f87171;
|
||||
--crepe-color-hover: rgb(255 255 255 / 0.08);
|
||||
--crepe-color-selected: rgb(96 165 250 / 0.25);
|
||||
--crepe-color-on-background: var(--card-foreground);
|
||||
/* Surface backs code blocks, tables, and the floating menus — use an opaque
|
||||
* themed surface so menus read clearly over content. */
|
||||
--crepe-color-surface: var(--popover);
|
||||
--crepe-color-surface-low: var(--muted);
|
||||
--crepe-color-on-surface: var(--popover-foreground);
|
||||
--crepe-color-on-surface-variant: var(--muted-foreground);
|
||||
--crepe-color-outline: var(--border);
|
||||
--crepe-color-primary: var(--primary);
|
||||
--crepe-color-secondary: color-mix(in oklch, var(--primary) 22%, transparent);
|
||||
--crepe-color-on-secondary: var(--foreground);
|
||||
--crepe-color-inverse: var(--foreground);
|
||||
--crepe-color-on-inverse: var(--background);
|
||||
--crepe-color-inline-code: var(--primary);
|
||||
--crepe-color-inline-area: var(--muted);
|
||||
--crepe-color-error: var(--destructive);
|
||||
--crepe-color-hover: var(--accent);
|
||||
--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. */
|
||||
--crepe-font-default: inherit;
|
||||
--crepe-font-title: inherit;
|
||||
@@ -57,7 +64,7 @@
|
||||
.llink-crepe .milkdown .ProseMirror {
|
||||
padding: 0;
|
||||
outline: none;
|
||||
caret-color: white;
|
||||
caret-color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Crepe's default heading scale (42px h1) is sized for a document editor;
|
||||
@@ -103,33 +110,19 @@
|
||||
line-height: 1.375rem;
|
||||
font-weight: 700;
|
||||
margin-top: 12px;
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.llink-crepe .milkdown .ProseMirror > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Images come from URLs only (no stable public upload URL), so hide the
|
||||
* file uploader — the placeholder then just prompts for a link. */
|
||||
.llink-crepe
|
||||
.milkdown
|
||||
:is(.milkdown-image-block, .milkdown-image-inline)
|
||||
.placeholder
|
||||
.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 {
|
||||
/* Image *links* render through the base commonmark schema (the ImageBlock
|
||||
* feature is off — see markdown-editor.tsx). Keep them within the content
|
||||
* column and softly rounded. */
|
||||
.llink-crepe .milkdown .ProseMirror img {
|
||||
max-width: 100%;
|
||||
max-height: 420px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
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 '@milkdown/crepe/theme/common/style.css';
|
||||
import '@milkdown/crepe/theme/frame-dark.css';
|
||||
import './markdown-editor.css';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { transferFiles } from '@/lib/data-transfer';
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
/** 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.Toolbar]: !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.TopBar]: false,
|
||||
[Crepe.Feature.AI]: false,
|
||||
},
|
||||
featureConfigs: {
|
||||
[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);
|
||||
});
|
||||
});
|
||||
|
||||
// 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(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Paperclip } from 'lucide-react';
|
||||
import type { RecordingMode } from '@/stores/media-settings-store';
|
||||
import { CenteredWaveform } from '@/components/audio/centered-waveform';
|
||||
@@ -7,10 +8,7 @@ import { useObjectUrl } from '@/hooks/use-object-url';
|
||||
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
||||
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
RECORDING_MAX_DURATION_SECONDS,
|
||||
RECORDING_WARNING_SECONDS,
|
||||
} from '@/lib/constants';
|
||||
import { RECORDING_MAX_DURATION_SECONDS } from '@/lib/constants';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { KeyHint } from '@/components/key-hint';
|
||||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||||
@@ -38,41 +36,32 @@ interface RecordingOverlayProps {
|
||||
objectFit?: 'cover' | 'contain';
|
||||
}
|
||||
|
||||
const WARNING_AT_SECONDS =
|
||||
RECORDING_MAX_DURATION_SECONDS - RECORDING_WARNING_SECONDS;
|
||||
|
||||
/**
|
||||
* Tracks elapsed recording time and drives the time-limit UI. Keeps the
|
||||
* recorder itself unaware of limits: when the cap is reached it dispatches the
|
||||
* standard `stop` intent (the same path as releasing the ` key), which finishes
|
||||
* the recording into the review step.
|
||||
*/
|
||||
function useRecordingCountdown(active: boolean) {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const requestIntent = useComposeIntentStore((s) => s.request);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
let toastSent = false;
|
||||
const start = Date.now();
|
||||
let stopped = false;
|
||||
// Tick faster than 1s so the auto-stop lands within ~250ms of the cap, but
|
||||
// only re-render when the whole-second value actually changes.
|
||||
const interval = setInterval(() => {
|
||||
const seconds = Math.floor((Date.now() - start) / 1000);
|
||||
setElapsed((prev) => (prev === seconds ? prev : seconds));
|
||||
if (seconds >= RECORDING_MAX_DURATION_SECONDS && !stopped) {
|
||||
stopped = true;
|
||||
requestIntent('stop');
|
||||
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;
|
||||
}
|
||||
}, 250);
|
||||
}, 1000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
setElapsed(0);
|
||||
};
|
||||
}, [active, requestIntent]);
|
||||
}, [active]);
|
||||
|
||||
return { elapsed, isWarning: elapsed >= WARNING_AT_SECONDS };
|
||||
return { elapsed, isWarning: elapsed >= RECORDING_MAX_DURATION_SECONDS };
|
||||
}
|
||||
|
||||
function RecordingTimer({
|
||||
@@ -189,7 +178,7 @@ export function RecordingOverlay({
|
||||
return (
|
||||
<div
|
||||
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',
|
||||
isRecording && isWarning && 'record-warning-glow',
|
||||
)}
|
||||
@@ -241,7 +230,7 @@ export function RecordingOverlay({
|
||||
|
||||
{/* Bottom gradient scrim for keyboard hint readability */}
|
||||
{(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 */}
|
||||
|
||||
@@ -63,37 +63,38 @@ export function TaskComposeStep({
|
||||
|
||||
return (
|
||||
<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}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
|
||||
<div>
|
||||
<Label className="mb-1 text-xs text-white/50">Task</Label>
|
||||
<Label className="text-muted-foreground mb-1 text-xs">Task</Label>
|
||||
<Input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
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>
|
||||
<Label className="mb-1 text-xs text-white/50">Notes</Label>
|
||||
<Label className="text-muted-foreground mb-1 text-xs">Notes</Label>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
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>
|
||||
<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}>
|
||||
<SelectTrigger className="w-full border-white/10 bg-white/5 text-white">
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Unassigned" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -108,7 +109,7 @@ export function TaskComposeStep({
|
||||
</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)">
|
||||
cancel
|
||||
</KeyHint>
|
||||
|
||||
@@ -112,7 +112,7 @@ export function TextEditor({
|
||||
);
|
||||
|
||||
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)">
|
||||
cancel
|
||||
</KeyHint>
|
||||
@@ -161,8 +161,8 @@ export function TextEditor({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-50 flex items-center justify-center bg-black/90',
|
||||
isDragging && 'ring-2 ring-inset ring-white/30',
|
||||
'bg-background/95 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm',
|
||||
isDragging && 'ring-primary/40 ring-2 ring-inset',
|
||||
)}
|
||||
{...dropZoneProps}
|
||||
>
|
||||
@@ -174,7 +174,7 @@ export function TextEditor({
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message..."
|
||||
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.weight,
|
||||
)}
|
||||
@@ -189,13 +189,13 @@ export function TextEditor({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-50 flex items-center justify-center bg-black/90',
|
||||
isDragging && 'ring-2 ring-inset ring-white/30',
|
||||
'bg-background/95 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm',
|
||||
isDragging && 'ring-primary/40 ring-2 ring-inset',
|
||||
)}
|
||||
{...dropZoneProps}
|
||||
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,
|
||||
so we pad inside the editor (ProseMirror) instead. That keeps the
|
||||
menu's clipping bounds the full card rather than the inset box. */}
|
||||
@@ -208,7 +208,7 @@ export function TextEditor({
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</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 (0–1), 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);
|
||||
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="1–9">jump</KeyHint>
|
||||
<VideoAudioToggle />
|
||||
|
||||
@@ -38,14 +38,16 @@ export function DeletedParticleView({
|
||||
}, [paused, onEnded, particle.id]);
|
||||
|
||||
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">
|
||||
<Trash2 className="text-white/40 size-6" />
|
||||
<p className="text-white/70 text-base font-medium">
|
||||
<Trash2 className="text-muted-foreground size-6" />
|
||||
<p className="text-foreground text-base font-medium">
|
||||
This particle was deleted
|
||||
</p>
|
||||
{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>
|
||||
|
||||
@@ -141,7 +141,7 @@ export const MediaParticleView = forwardRef<
|
||||
|
||||
if (isAudio) {
|
||||
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
|
||||
ref={(el) => {
|
||||
audioRef.current = el;
|
||||
@@ -186,7 +186,7 @@ export const MediaParticleView = forwardRef<
|
||||
playsInline
|
||||
onEnded={onEnded}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
className="h-full w-full object-contain bg-black"
|
||||
className="bg-black h-full w-full object-contain"
|
||||
/>
|
||||
|
||||
{transcript && (
|
||||
|
||||
@@ -63,7 +63,7 @@ function ImageAttachment({
|
||||
);
|
||||
|
||||
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) => {
|
||||
@@ -78,7 +78,7 @@ function ImageAttachment({
|
||||
e.stopPropagation();
|
||||
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
|
||||
src={url}
|
||||
@@ -89,7 +89,7 @@ function ImageAttachment({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
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" />
|
||||
</span>
|
||||
@@ -123,14 +123,14 @@ function FileAttachment({
|
||||
<div
|
||||
role="button"
|
||||
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">
|
||||
<FileIcon className="size-4 shrink-0 text-white/60" />
|
||||
<span className="max-w-[10rem] truncate text-xs font-medium text-white/90">
|
||||
<FileIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<span className="text-foreground max-w-[10rem] truncate text-xs font-medium">
|
||||
{particle.properties.filename}
|
||||
</span>
|
||||
<span className="text-[10px] text-white/40">
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{formatFileSize(particle.properties.size_bytes)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@ function FileAttachment({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-white/70 hover:bg-white/10 hover:text-white"
|
||||
className="text-muted-foreground"
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<ExternalLink data-icon="inline-start" />
|
||||
@@ -147,7 +147,7 @@ function FileAttachment({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-white/70 hover:bg-white/10 hover:text-white"
|
||||
className="text-muted-foreground"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<Download data-icon="inline-start" />
|
||||
@@ -177,7 +177,7 @@ function CompactAttachmentItem({
|
||||
e.stopPropagation();
|
||||
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 ? (
|
||||
url ? (
|
||||
@@ -187,15 +187,15 @@ function CompactAttachmentItem({
|
||||
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}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-white/40">
|
||||
<span className="text-muted-foreground shrink-0 text-[10px]">
|
||||
{formatFileSize(particle.properties.size_bytes)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -191,7 +191,7 @@ const StreamRow = memo(function StreamRow({
|
||||
)}
|
||||
>
|
||||
{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}
|
||||
</kbd>
|
||||
)}
|
||||
@@ -220,8 +220,8 @@ const StreamRow = memo(function StreamRow({
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{hasActiveHuddle && (
|
||||
<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" />
|
||||
<span className="text-[10px] font-medium text-red-400">
|
||||
<Headphones className="size-3 text-red-600 dark:text-red-400" />
|
||||
<span className="text-[10px] font-medium text-red-600 dark:text-red-400">
|
||||
{huddleCount}
|
||||
</span>
|
||||
</span>
|
||||
@@ -298,7 +298,7 @@ const FolderRow = memo(function FolderRow({
|
||||
)}
|
||||
>
|
||||
{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}
|
||||
</kbd>
|
||||
)}
|
||||
@@ -356,7 +356,7 @@ const LeafRow = memo(function LeafRow({
|
||||
)}
|
||||
>
|
||||
{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}
|
||||
</kbd>
|
||||
)}
|
||||
|
||||
@@ -69,9 +69,9 @@ function MediaPreview({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-black/90">
|
||||
<Mic className="h-8 w-8 text-white/60" />
|
||||
<span className="font-mono text-xs text-white/50">{durationLabel}</span>
|
||||
<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" />
|
||||
<span className="font-mono text-xs">{durationLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -103,8 +103,8 @@ function VideoThumbnail({
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-black/80">
|
||||
<Video className="h-8 w-8 text-white/40" />
|
||||
<div className="bg-muted flex h-full w-full items-center justify-center">
|
||||
<Video className="text-muted-foreground h-8 w-8" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,7 +114,7 @@ function VideoThumbnail({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full bg-black">
|
||||
<div className="bg-muted relative h-full w-full">
|
||||
<video
|
||||
src={`${url}#t=2`}
|
||||
preload="metadata"
|
||||
@@ -122,7 +122,7 @@ function VideoThumbnail({
|
||||
playsInline
|
||||
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}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -138,7 +138,7 @@ function LeafParticleView({
|
||||
})();
|
||||
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -77,10 +77,10 @@ export function PlaybackPageIndicator({
|
||||
className="group relative block h-3 w-full"
|
||||
>
|
||||
{/* 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 */}
|
||||
<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={{
|
||||
width:
|
||||
i < current
|
||||
@@ -107,7 +107,7 @@ export function PlaybackPageIndicator({
|
||||
)}
|
||||
</div>
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
@@ -141,7 +141,7 @@ function GhostStub({
|
||||
className="group relative block h-3 w-2 shrink-0"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -166,7 +166,7 @@ function SegmentPresenceAvatars({
|
||||
className={
|
||||
onlineHumanIds?.has(human.humanId)
|
||||
? 'ring-2 ring-green-500'
|
||||
: 'ring-1 ring-black/50'
|
||||
: 'ring-1 ring-background'
|
||||
}
|
||||
avatarObjectId={human.avatarObjectId}
|
||||
initials={human.emailPrefix.slice(0, 2).toUpperCase()}
|
||||
@@ -178,7 +178,9 @@ function SegmentPresenceAvatars({
|
||||
</Tooltip>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -77,12 +77,12 @@ export function ReactionBar({
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-full px-2 py-0.5 text-xs backdrop-blur-sm transition-colors',
|
||||
isMine
|
||||
? 'bg-white/20 ring-1 ring-white/40'
|
||||
: 'bg-black/40 hover:bg-black/50',
|
||||
? 'bg-primary/15 text-foreground ring-1 ring-primary/40'
|
||||
: 'bg-card/70 text-foreground border border-border hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">{emoji}</span>
|
||||
<span className="text-white/80">{reactors.length}</span>
|
||||
<span className="text-muted-foreground">{reactors.length}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
@@ -109,8 +109,8 @@ export function ReactionBar({
|
||||
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',
|
||||
isMine
|
||||
? 'bg-white/20 ring-1 ring-white/40'
|
||||
: 'bg-black/40 hover:bg-black/50',
|
||||
? 'bg-primary/15 text-foreground ring-1 ring-primary/40'
|
||||
: 'bg-card/70 text-foreground border border-border hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<HumanAvatar
|
||||
@@ -118,11 +118,11 @@ export function ReactionBar({
|
||||
className="shrink-0"
|
||||
avatarObjectId={firstReactor.avatarObjectId}
|
||||
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 && (
|
||||
<span className="shrink-0 text-white/60">
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{reactors.length}
|
||||
</span>
|
||||
)}
|
||||
@@ -154,7 +154,7 @@ export function ReactionBar({
|
||||
|
||||
{/* Picker / actions */}
|
||||
{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) => {
|
||||
if (activeEmojis.includes(emoji)) return null;
|
||||
return (
|
||||
@@ -164,7 +164,7 @@ export function ReactionBar({
|
||||
e.stopPropagation();
|
||||
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}
|
||||
</button>
|
||||
@@ -175,9 +175,9 @@ export function ReactionBar({
|
||||
e.stopPropagation();
|
||||
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>
|
||||
</div>
|
||||
) : (
|
||||
@@ -189,14 +189,14 @@ export function ReactionBar({
|
||||
e.stopPropagation();
|
||||
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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
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
|
||||
</kbd>
|
||||
</TooltipContent>
|
||||
@@ -206,9 +206,9 @@ export function ReactionBar({
|
||||
e.stopPropagation();
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -56,17 +56,17 @@ export function RenameStreamOverlay({
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-scrim/60 backdrop-blur-sm"
|
||||
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">
|
||||
<h2 className="text-sm font-semibold text-white/70">Rename stream</h2>
|
||||
<h2 className="text-sm font-semibold">Rename stream</h2>
|
||||
<KeyHint
|
||||
keys="Esc"
|
||||
onClick={onClose}
|
||||
title="Close (or press Esc)"
|
||||
className="text-xs text-white/30"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
to close
|
||||
</KeyHint>
|
||||
@@ -85,7 +85,6 @@ export function RenameStreamOverlay({
|
||||
}
|
||||
}}
|
||||
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">
|
||||
|
||||
@@ -61,7 +61,7 @@ export function BottomBar({
|
||||
<div className="flex items-center justify-center px-3 pt-2 gap-2">
|
||||
{exitRemainingMs !== null && (
|
||||
<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
|
||||
</span>
|
||||
</div>
|
||||
@@ -91,7 +91,7 @@ export function StreamViewControls({
|
||||
}) {
|
||||
const requestIntent = useComposeIntentStore((s) => s.request);
|
||||
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 && (
|
||||
<KeyHint
|
||||
keys="Esc"
|
||||
|
||||
@@ -39,17 +39,17 @@ export function StreamListSidebar({
|
||||
}, [currentIndex]);
|
||||
|
||||
return (
|
||||
<aside className="dark flex w-60 shrink-0 flex-col overflow-hidden border-l border-white/10 bg-zinc-950">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-4 py-3">
|
||||
<List className="size-3.5 text-white/40" />
|
||||
<span className="truncate text-sm font-medium text-white/90 mr-auto">
|
||||
<aside className="bg-sidebar text-sidebar-foreground border-border flex w-60 shrink-0 flex-col overflow-hidden border-l">
|
||||
<div className="border-border flex shrink-0 items-center gap-2 border-b px-4 py-3">
|
||||
<List className="text-muted-foreground size-3.5" />
|
||||
<span className="mr-auto truncate text-sm font-medium">
|
||||
{items.length} messages
|
||||
</span>
|
||||
<KeyHint
|
||||
keys="L"
|
||||
onClick={onToggle}
|
||||
title="Hide list (or press L)"
|
||||
className="text-xs text-white/40"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
to close
|
||||
</KeyHint>
|
||||
@@ -72,7 +72,7 @@ export function StreamListSidebar({
|
||||
</div>
|
||||
))}
|
||||
{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.
|
||||
</p>
|
||||
)}
|
||||
@@ -106,7 +106,7 @@ function ChatRow({
|
||||
}}
|
||||
className={cn(
|
||||
'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
|
||||
@@ -114,14 +114,14 @@ function ChatRow({
|
||||
className="mt-0.5 shrink-0"
|
||||
initials={sender.initials}
|
||||
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 overflow-hidden">
|
||||
<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}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-white/35">
|
||||
<span className="text-muted-foreground shrink-0 text-[10px]">
|
||||
<RelativeTimestamp date={particle.created_at} />
|
||||
</span>
|
||||
</div>
|
||||
@@ -134,14 +134,16 @@ function ChatRow({
|
||||
function ChatRowContent({ particle }: { particle: Particle }) {
|
||||
if (isParticleDeleted(particle)) {
|
||||
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) {
|
||||
case 'text':
|
||||
return (
|
||||
<p className="line-clamp-2 text-xs leading-relaxed [overflow-wrap:anywhere] 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}
|
||||
</p>
|
||||
);
|
||||
@@ -166,13 +168,13 @@ function ChatRowContent({ particle }: { particle: Particle }) {
|
||||
: '';
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1.5 text-xs text-white/70">
|
||||
<Icon className="size-3.5 shrink-0 text-white/50" />
|
||||
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<Icon className="text-muted-foreground size-3.5 shrink-0" />
|
||||
{label}
|
||||
{duration}
|
||||
</span>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
@@ -181,8 +183,8 @@ function ChatRowContent({ particle }: { particle: Particle }) {
|
||||
}
|
||||
case 'file':
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-xs text-white/70">
|
||||
<FileText className="size-3.5 shrink-0 text-white/50" />
|
||||
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<FileText className="text-muted-foreground size-3.5 shrink-0" />
|
||||
<span className="truncate">{particle.properties.filename}</span>
|
||||
</span>
|
||||
);
|
||||
@@ -191,21 +193,26 @@ function ChatRowContent({ particle }: { particle: Particle }) {
|
||||
const doneCount = checklist.filter((item) => item.done).length;
|
||||
return (
|
||||
<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
|
||||
className={cn(
|
||||
'size-3.5 shrink-0',
|
||||
done ? 'text-emerald-400' : 'text-white/50',
|
||||
done
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn('truncate', done && 'text-white/40 line-through')}
|
||||
className={cn(
|
||||
'truncate',
|
||||
done && 'text-muted-foreground line-through',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</span>
|
||||
{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
|
||||
</span>
|
||||
)}
|
||||
@@ -214,11 +221,11 @@ function ChatRowContent({ particle }: { particle: Particle }) {
|
||||
}
|
||||
case 'paper':
|
||||
return (
|
||||
<p className="truncate text-xs text-white/70">
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{particle.properties.title}
|
||||
</p>
|
||||
);
|
||||
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(
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
className="bg-scrim/60 absolute inset-0 backdrop-blur-sm"
|
||||
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 */}
|
||||
<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
|
||||
keys="Esc"
|
||||
onClick={onClose}
|
||||
title="Close (or press Esc)"
|
||||
className="text-xs text-white/30"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
to close
|
||||
</KeyHint>
|
||||
@@ -116,11 +116,11 @@ export function StreamMembersOverlay({
|
||||
|
||||
{/* Visibility */}
|
||||
<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
|
||||
</h3>
|
||||
{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
|
||||
active={visibility.mode === 'network'}
|
||||
icon={<Globe className="size-3.5" />}
|
||||
@@ -135,15 +135,15 @@ export function StreamMembersOverlay({
|
||||
/>
|
||||
</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' ? (
|
||||
<>
|
||||
<Globe className="size-3.5 text-white/40" />
|
||||
<Globe className="text-muted-foreground size-3.5" />
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
@@ -153,9 +153,11 @@ export function StreamMembersOverlay({
|
||||
|
||||
{/* Member list */}
|
||||
<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'}{' '}
|
||||
<span className="ml-1 text-white/20">{memberIds.length}</span>
|
||||
<span className="text-muted-foreground ml-1">
|
||||
{memberIds.length}
|
||||
</span>
|
||||
</h3>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<ul className="flex flex-col gap-0.5 pr-2">
|
||||
@@ -167,7 +169,7 @@ export function StreamMembersOverlay({
|
||||
return (
|
||||
<li
|
||||
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
|
||||
size="sm"
|
||||
@@ -178,13 +180,13 @@ export function StreamMembersOverlay({
|
||||
<span
|
||||
className={cn(
|
||||
'flex-1 truncate',
|
||||
!display.exists && 'italic text-white/40',
|
||||
!display.exists && 'text-muted-foreground italic',
|
||||
)}
|
||||
>
|
||||
{display.displayName}
|
||||
</span>
|
||||
{isCreatorRow && (
|
||||
<span className="text-[10px] uppercase tracking-wider text-white/30">
|
||||
<span className="text-muted-foreground text-[10px] uppercase tracking-wider">
|
||||
Creator
|
||||
</span>
|
||||
)}
|
||||
@@ -192,7 +194,7 @@ export function StreamMembersOverlay({
|
||||
<button
|
||||
type="button"
|
||||
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}`}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
@@ -209,8 +211,8 @@ export function StreamMembersOverlay({
|
||||
{isCreator &&
|
||||
visibility.mode === 'custom' &&
|
||||
availableToAdd.length > 0 && (
|
||||
<section className="mt-4 border-t border-white/5 pt-4">
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||
<section className="border-border mt-4 border-t pt-4">
|
||||
<h3 className="text-muted-foreground mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest">
|
||||
<UserPlus className="size-3" />
|
||||
Add people
|
||||
</h3>
|
||||
@@ -222,7 +224,7 @@ export function StreamMembersOverlay({
|
||||
type="button"
|
||||
onClick={() => addMember(human.id)}
|
||||
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
|
||||
@@ -234,7 +236,7 @@ export function StreamMembersOverlay({
|
||||
<span className="flex-1 truncate">
|
||||
{human.email_prefix}
|
||||
</span>
|
||||
<UserPlus className="size-3.5 text-white/30" />
|
||||
<UserPlus className="text-muted-foreground size-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -246,7 +248,7 @@ export function StreamMembersOverlay({
|
||||
{isCreator &&
|
||||
visibility.mode === 'custom' &&
|
||||
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" />
|
||||
Everyone in the network is already a member
|
||||
</p>
|
||||
@@ -275,8 +277,8 @@ function VisibilityPill({
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors',
|
||||
active
|
||||
? 'bg-white/10 text-white/90'
|
||||
: 'text-white/50 hover:text-white/80',
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -92,7 +92,7 @@ export function TopBar({
|
||||
<div className="drag-region flex flex-row px-2 gap-1 items-center">
|
||||
<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>
|
||||
{streamParticle && (
|
||||
<>
|
||||
@@ -139,7 +139,7 @@ export function TopBar({
|
||||
size="sm"
|
||||
avatarObjectId={display.avatarObjectId}
|
||||
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>
|
||||
<TooltipContent>{display.email}</TooltipContent>
|
||||
@@ -147,12 +147,14 @@ export function TopBar({
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{!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" />
|
||||
Closed
|
||||
</span>
|
||||
@@ -280,16 +282,16 @@ function MembersIndicator({
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
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 ? (
|
||||
<>
|
||||
<Globe className="size-3 text-white/50" />
|
||||
<Globe className="text-muted-foreground size-3" />
|
||||
<span>Everyone</span>
|
||||
</>
|
||||
) : isPrivate ? (
|
||||
<>
|
||||
<Lock className="size-3 text-white/50" />
|
||||
<Lock className="text-muted-foreground size-3" />
|
||||
<span>Private</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -306,7 +308,7 @@ function MembersIndicator({
|
||||
))}
|
||||
</AvatarGroup>
|
||||
{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) {
|
||||
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">
|
||||
No particles in this stream yet
|
||||
</p>
|
||||
@@ -449,6 +449,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
paused={paused}
|
||||
onEnded={handleParticleEnded}
|
||||
onProgress={setProgress}
|
||||
immersive
|
||||
/>
|
||||
);
|
||||
default:
|
||||
@@ -459,15 +460,15 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-black">
|
||||
<div className="bg-background flex h-screen overflow-hidden">
|
||||
{/* Stream chrome — immersive playback column */}
|
||||
<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}
|
||||
onMouseLeave={() => setShowControls(false)}
|
||||
>
|
||||
{/* 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 */}
|
||||
<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">
|
||||
{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
|
||||
</div>
|
||||
)}
|
||||
@@ -496,7 +497,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
type="button"
|
||||
onClick={resume}
|
||||
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" />
|
||||
Paused
|
||||
@@ -540,7 +541,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
/>
|
||||
|
||||
{/* 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
|
||||
|
||||
@@ -28,6 +28,8 @@ import { useFixedDwell } from '@/hooks/use-fixed-dwell';
|
||||
import { useLiveDraftField } from '@/hooks/use-live-draft-field';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
import { isTypingTarget } from '@/lib/keyboard';
|
||||
import { KeyHint } from '@/components/key-hint';
|
||||
|
||||
type TaskParticle = Extract<Particle, { type: 'task' }>;
|
||||
|
||||
@@ -37,11 +39,28 @@ interface TaskParticleViewProps {
|
||||
paused: boolean;
|
||||
onEnded: () => 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 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(
|
||||
containerPath: ParticlePath,
|
||||
particleId: string,
|
||||
@@ -56,6 +75,7 @@ export function TaskParticleView({
|
||||
paused,
|
||||
onEnded,
|
||||
onProgress,
|
||||
immersive = false,
|
||||
}: TaskParticleViewProps) {
|
||||
const { networkId } = parseParticlePath(containerPath);
|
||||
const network = useNetwork(networkId);
|
||||
@@ -74,6 +94,33 @@ export function TaskParticleView({
|
||||
const [editing, setEditing] = useState(false);
|
||||
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({
|
||||
id: particle.id,
|
||||
durationS: DWELL_DURATION_S,
|
||||
@@ -165,9 +212,21 @@ export function TaskParticleView({
|
||||
const doneCount = checklist.filter((item) => item.done).length;
|
||||
|
||||
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
|
||||
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)}
|
||||
onBlurCapture={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false);
|
||||
@@ -177,7 +236,7 @@ export function TaskParticleView({
|
||||
<Checkbox
|
||||
checked={done}
|
||||
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'}
|
||||
/>
|
||||
<input
|
||||
@@ -187,8 +246,8 @@ export function TaskParticleView({
|
||||
onBlur={titleField.onBlur}
|
||||
placeholder="Task title"
|
||||
className={cn(
|
||||
'w-full bg-transparent text-2xl font-semibold text-white outline-none placeholder:text-white/30',
|
||||
done && 'text-white/50 line-through',
|
||||
'text-foreground placeholder:text-muted-foreground w-full bg-transparent text-2xl font-semibold outline-none',
|
||||
done && 'text-muted-foreground line-through',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -199,12 +258,12 @@ export function TaskParticleView({
|
||||
onFocus={notesField.onFocus}
|
||||
onBlur={notesField.onBlur}
|
||||
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">
|
||||
{checklist.length > 0 && (
|
||||
<span className="text-xs text-white/40">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{doneCount} / {checklist.length} done
|
||||
</span>
|
||||
)}
|
||||
@@ -227,10 +286,7 @@ export function TaskParticleView({
|
||||
value={assigned_to ?? UNASSIGNED}
|
||||
onValueChange={handleAssign}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-fit gap-2 border-white/15 bg-white/5 text-white/80"
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-fit gap-2">
|
||||
{assigned_to && assignee.exists && (
|
||||
<HumanAvatar
|
||||
size="sm"
|
||||
@@ -251,6 +307,16 @@ export function TaskParticleView({
|
||||
</Select>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -276,7 +342,7 @@ function ChecklistItemRow({
|
||||
<Checkbox
|
||||
checked={item.done}
|
||||
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={
|
||||
item.done ? 'Mark subtask as not done' : 'Mark subtask as done'
|
||||
}
|
||||
@@ -288,14 +354,14 @@ function ChecklistItemRow({
|
||||
onBlur={textField.onBlur}
|
||||
placeholder="Subtask"
|
||||
className={cn(
|
||||
'w-full bg-transparent text-sm text-white/90 outline-none placeholder:text-white/30',
|
||||
item.done && 'text-white/40 line-through',
|
||||
'text-foreground placeholder:text-muted-foreground w-full bg-transparent text-sm outline-none',
|
||||
item.done && 'text-muted-foreground line-through',
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
@@ -316,7 +382,7 @@ function AddChecklistItemRow({ onAdd }: { onAdd: (text: string) => void }) {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Plus className="size-4 text-white/30" />
|
||||
<Plus className="text-muted-foreground size-4" />
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
@@ -328,7 +394,7 @@ function AddChecklistItemRow({ onAdd }: { onAdd: (text: string) => void }) {
|
||||
}}
|
||||
onBlur={submit}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -122,7 +122,7 @@ export function TextParticleView({
|
||||
setIsEditing(true);
|
||||
}}
|
||||
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" />
|
||||
Edit
|
||||
@@ -130,7 +130,7 @@ export function TextParticleView({
|
||||
);
|
||||
|
||||
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} />
|
||||
</span>
|
||||
);
|
||||
@@ -146,7 +146,7 @@ export function TextParticleView({
|
||||
// Mode 1: bare URLs only — show link cards centered
|
||||
if (linksOnly && !hasAttachments) {
|
||||
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} />
|
||||
{editedLabel && (
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2">
|
||||
@@ -167,10 +167,10 @@ export function TextParticleView({
|
||||
) {
|
||||
const style = getImmersiveTextStyle(content.length);
|
||||
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
|
||||
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.weight,
|
||||
)}
|
||||
@@ -186,8 +186,8 @@ export function TextParticleView({
|
||||
|
||||
// Mode 3: card layout
|
||||
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="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="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 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
|
||||
key={content}
|
||||
value={content}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function TextReactionInput({
|
||||
return (
|
||||
<div
|
||||
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
|
||||
ref={inputRef}
|
||||
@@ -74,12 +74,14 @@ export function TextReactionInput({
|
||||
}}
|
||||
placeholder="Quick reply…"
|
||||
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
|
||||
className={cn(
|
||||
'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}
|
||||
@@ -91,8 +93,8 @@ export function TextReactionInput({
|
||||
className={cn(
|
||||
'ml-1 flex size-6 items-center justify-center rounded-full transition-colors',
|
||||
canSubmit
|
||||
? 'bg-white/20 text-white hover:bg-white/30'
|
||||
: 'text-white/30',
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
aria-label="Send reaction"
|
||||
>
|
||||
|
||||
@@ -12,11 +12,16 @@ import {
|
||||
Volume2,
|
||||
ArrowLeft,
|
||||
Camera,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
Palette,
|
||||
} from 'lucide-react';
|
||||
import { HumanAvatar } from '@/components/human-avatar';
|
||||
import { AvatarEditDialog } from '@/features/settings/avatar-edit-dialog';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { WindowControls } from '@/components/window-controls';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Muted } from '@/components/ui/typography';
|
||||
@@ -24,6 +29,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { CopyableEmail } from '@/components/copyable-email';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSoundEffectsStore } from '@/stores/sound-effects-store';
|
||||
import { useThemeStore, type ThemeMode } from '@/stores/theme-store';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { logError, toUserMessage } from '@/lib/errors';
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -150,7 +200,7 @@ export default function SettingsPage() {
|
||||
initials={initials}
|
||||
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">
|
||||
<Camera className="size-2.5" />
|
||||
</span>
|
||||
@@ -165,6 +215,12 @@ export default function SettingsPage() {
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsGroup title="Appearance">
|
||||
<ThemeSetting />
|
||||
</SettingsGroup>
|
||||
|
||||
<Separator className="mt-4" />
|
||||
|
||||
<SettingsGroup title="Notifications">
|
||||
<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">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { transferFiles } from '@/lib/data-transfer';
|
||||
|
||||
interface UseFileInputOptions {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
@@ -48,15 +49,20 @@ export function useFileInput({
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
// Capture phase so file pastes always become attachments — ProseMirror
|
||||
// would otherwise inline pasted images as ephemeral blob: URLs. Mixed
|
||||
// clipboards (e.g. Excel/Word ship an image rendition alongside the text)
|
||||
// must still paste as text, so only file-only pastes are intercepted.
|
||||
// Capture phase so file pastes always become attachments — the markdown
|
||||
// editor would otherwise inline a pasted image as an ephemeral blob: URL.
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const data = e.clipboardData;
|
||||
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.stopPropagation();
|
||||
onFilesRef.current(files);
|
||||
@@ -105,7 +111,8 @@ export function useFileInput({
|
||||
e.stopPropagation();
|
||||
dragCountRef.current = 0;
|
||||
setIsDragging(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
|
||||
const files = transferFiles(e.dataTransfer);
|
||||
if (files.length > 0) {
|
||||
onFilesRef.current(files);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ export const MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
|
||||
/** Maximum number of file attachments per particle. */
|
||||
export const MAX_ATTACHMENTS = 10;
|
||||
|
||||
/** Maximum duration for a media (audio/video) recording, in seconds. */
|
||||
/** Maximum recommended duration for a media (audio/video) recording, in seconds. */
|
||||
export const RECORDING_MAX_DURATION_SECONDS = 60;
|
||||
|
||||
/** When this many seconds or fewer remain, show the red warning state. */
|
||||
export const RECORDING_WARNING_SECONDS = 10;
|
||||
/**
|
||||
* 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';
|
||||
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { initSentryRenderer } from '@/lib/sentry';
|
||||
import '@/stores/theme-store';
|
||||
import './styles/globals.css';
|
||||
|
||||
initSentryRenderer();
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -47,75 +47,92 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--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 {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--background: oklch(0.985 0.006 83);
|
||||
--foreground: oklch(0.255 0.012 64);
|
||||
--card: oklch(0.998 0.003 90);
|
||||
--card-foreground: oklch(0.255 0.012 64);
|
||||
--popover: oklch(0.998 0.003 90);
|
||||
--popover-foreground: oklch(0.255 0.012 64);
|
||||
--primary: oklch(0.62 0.128 62);
|
||||
--primary-foreground: oklch(0.99 0.006 90);
|
||||
--secondary: oklch(0.945 0.014 78);
|
||||
--secondary-foreground: oklch(0.32 0.014 60);
|
||||
--muted: oklch(0.945 0.012 78);
|
||||
--muted-foreground: oklch(0.535 0.02 62);
|
||||
--accent: oklch(0.92 0.03 70);
|
||||
--accent-foreground: oklch(0.3 0.035 55);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--border: oklch(0.9 0.014 75);
|
||||
--input: oklch(0.9 0.014 75);
|
||||
--ring: oklch(0.62 0.128 62);
|
||||
--chart-1: oklch(0.66 0.14 60);
|
||||
--chart-2: oklch(0.7 0.12 95);
|
||||
--chart-3: oklch(0.58 0.09 45);
|
||||
--chart-4: oklch(0.75 0.13 80);
|
||||
--chart-5: oklch(0.6 0.11 30);
|
||||
--sidebar: oklch(0.965 0.01 80);
|
||||
--sidebar-foreground: oklch(0.255 0.012 64);
|
||||
--sidebar-primary: oklch(0.62 0.128 62);
|
||||
--sidebar-primary-foreground: oklch(0.99 0.006 90);
|
||||
--sidebar-accent: oklch(0.92 0.03 70);
|
||||
--sidebar-accent-foreground: oklch(0.3 0.035 55);
|
||||
--sidebar-border: oklch(0.9 0.014 75);
|
||||
--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 {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--background: oklch(0.19 0.012 58);
|
||||
--foreground: oklch(0.955 0.008 85);
|
||||
--card: oklch(0.232 0.014 56);
|
||||
--card-foreground: oklch(0.955 0.008 85);
|
||||
--popover: oklch(0.232 0.014 56);
|
||||
--popover-foreground: oklch(0.955 0.008 85);
|
||||
--primary: oklch(0.78 0.13 78);
|
||||
--primary-foreground: oklch(0.24 0.03 60);
|
||||
--secondary: oklch(0.285 0.014 54);
|
||||
--secondary-foreground: oklch(0.955 0.008 85);
|
||||
--muted: oklch(0.285 0.014 54);
|
||||
--muted-foreground: oklch(0.72 0.018 72);
|
||||
--accent: oklch(0.33 0.032 56);
|
||||
--accent-foreground: oklch(0.955 0.008 85);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--border: oklch(0.92 0.03 85 / 10%);
|
||||
--input: oklch(0.92 0.03 85 / 15%);
|
||||
--ring: oklch(0.78 0.13 78);
|
||||
--chart-1: oklch(0.78 0.13 78);
|
||||
--chart-2: oklch(0.7 0.12 50);
|
||||
--chart-3: oklch(0.72 0.11 95);
|
||||
--chart-4: oklch(0.65 0.13 35);
|
||||
--chart-5: oklch(0.6 0.1 60);
|
||||
--sidebar: oklch(0.165 0.012 58);
|
||||
--sidebar-foreground: oklch(0.955 0.008 85);
|
||||
--sidebar-primary: oklch(0.78 0.13 78);
|
||||
--sidebar-primary-foreground: oklch(0.24 0.03 60);
|
||||
--sidebar-accent: oklch(0.33 0.032 56);
|
||||
--sidebar-accent-foreground: oklch(0.955 0.008 85);
|
||||
--sidebar-border: oklch(0.92 0.03 85 / 10%);
|
||||
--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 {
|
||||
@@ -130,7 +147,7 @@
|
||||
/* Scrollbar styling */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: oklch(1 0 0 / 20%) transparent;
|
||||
scrollbar-color: var(--scrollbar-thumb) transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@@ -143,12 +160,12 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(1 0 0 / 20%);
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-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.
|
||||
@@ -160,13 +177,13 @@
|
||||
|
||||
.scrollbar-card::-webkit-scrollbar-thumb,
|
||||
.scrollbar-card ::-webkit-scrollbar-thumb {
|
||||
background: oklch(1 0 0 / 30%);
|
||||
background: var(--scrollbar-thumb-strong);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.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
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
<!doctype html>
|
||||
<html class="dark">
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/png" href="/icon.png" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from '@/App';
|
||||
import { initSentryRenderer } from '@/lib/sentry';
|
||||
import '@/stores/theme-store';
|
||||
import '@/styles/globals.css';
|
||||
|
||||
initSentryRenderer();
|
||||
|
||||
@@ -55,6 +55,13 @@ const config: ExpoConfig = {
|
||||
'Flowy uses your microphone to record voice messages.',
|
||||
},
|
||||
],
|
||||
[
|
||||
'expo-image-picker',
|
||||
{
|
||||
photosPermission:
|
||||
'Flowy uses your photo library to set your profile picture.',
|
||||
},
|
||||
],
|
||||
[
|
||||
'expo-notifications',
|
||||
{
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-file-system": "~19.0.16",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image-picker": "~17.0.8",
|
||||
"expo-notifications": "~0.32.17",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FileSystemUploadType, uploadAsync } from 'expo-file-system/legacy';
|
||||
import { appConfig } from '@/config/env';
|
||||
import { ApiError } from '@/lib/errors';
|
||||
import type { z } from 'zod';
|
||||
@@ -136,6 +137,45 @@ class ApiClient {
|
||||
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 ---
|
||||
|
||||
async registerPushToken(data: {
|
||||
|
||||
@@ -6,6 +6,7 @@ export const HumanSchema = z.object({
|
||||
email: z.string().email(),
|
||||
email_prefix: z.string(),
|
||||
email_notifications_enabled: z.boolean(),
|
||||
avatar_object_id: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type Human = z.infer<typeof HumanSchema>;
|
||||
@@ -145,14 +146,21 @@ export const TextPropertiesSchema = z.object({
|
||||
});
|
||||
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(),
|
||||
description: z.string(),
|
||||
status: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
checklist: z.array(ChecklistItemSchema).optional(),
|
||||
// humanId
|
||||
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({
|
||||
title: z.string(),
|
||||
@@ -193,7 +201,7 @@ export interface ParticlePropertiesMap {
|
||||
media: MediaProperties;
|
||||
file: FileProperties;
|
||||
text: TextProperties;
|
||||
quest: QuestProperties;
|
||||
task: TaskProperties;
|
||||
paper: PaperProperties;
|
||||
}
|
||||
|
||||
@@ -247,8 +255,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [
|
||||
...TombstoneFields,
|
||||
}),
|
||||
ParticleBaseSchema.extend({
|
||||
type: z.literal('quest'),
|
||||
properties: QuestPropertiesSchema,
|
||||
type: z.literal('task'),
|
||||
properties: TaskPropertiesSchema,
|
||||
reactions: ReactionsSchema,
|
||||
...TombstoneFields,
|
||||
}),
|
||||
ParticleBaseSchema.extend({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Text, View } from 'react-native';
|
||||
import { Image, Text, View } from 'react-native';
|
||||
import type { Human } from '@/api/types';
|
||||
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Size = 'xs' | 'sm' | 'md';
|
||||
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
interface AvatarProps {
|
||||
humanId: string | null | undefined;
|
||||
@@ -13,6 +14,8 @@ interface AvatarProps {
|
||||
online?: boolean;
|
||||
/** Background ring used to separate stacked avatars from the chrome. */
|
||||
stackBg?: string;
|
||||
/** Initials shown when the human can't be resolved (e.g. a group stream). */
|
||||
fallbackInitials?: 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 },
|
||||
sm: { box: 'h-9 w-9', text: 'text-xs', 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
|
||||
* stack ring used to visually separate overlapping avatars on a busy chrome.
|
||||
* Matches desktop's avatar + presence pattern (`ring-2 ring-green-500`).
|
||||
* Human avatar: renders the profile picture when one is set (resolved to a
|
||||
* signed URL via React Query), otherwise initials. Supports an optional online
|
||||
* 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({
|
||||
humanId,
|
||||
@@ -33,15 +39,22 @@ export function Avatar({
|
||||
size = 'sm',
|
||||
online = false,
|
||||
stackBg,
|
||||
fallbackInitials,
|
||||
className,
|
||||
}: 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 initials =
|
||||
display.exists || fallbackInitials === undefined
|
||||
? display.initials
|
||||
: fallbackInitials;
|
||||
|
||||
return (
|
||||
<View
|
||||
className={cn(
|
||||
'bg-black/15 items-center justify-center rounded-full',
|
||||
'bg-black/15 items-center justify-center overflow-hidden rounded-full',
|
||||
dims.box,
|
||||
className,
|
||||
)}
|
||||
@@ -52,9 +65,13 @@ export function Avatar({
|
||||
borderColor: online ? '#22c55e' : (stackBg ?? 'transparent'),
|
||||
}}
|
||||
>
|
||||
<Text className={cn('text-white font-semibold', dims.text)}>
|
||||
{initials}
|
||||
</Text>
|
||||
{avatarUrl ? (
|
||||
<Image source={{ uri: avatarUrl }} className="h-full w-full" />
|
||||
) : (
|
||||
<Text className={cn('text-white font-semibold', dims.text)}>
|
||||
{initials}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
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 { useCameraPermissions, useMicrophonePermissions } from 'expo-camera';
|
||||
import { toast } from 'sonner-native';
|
||||
@@ -8,13 +13,18 @@ import { cn } from '@/lib/utils';
|
||||
import { useEvent } from '@/hooks/use-event';
|
||||
import { usePlaybackPauseStore } from '@/stores/playback-pause-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 {
|
||||
useStreamComposingBroadcastOptional,
|
||||
type ComposingMode,
|
||||
} from '@/features/stream-view/stream-presence-context';
|
||||
import { TextComposeModal } from './TextComposeModal';
|
||||
import { TaskComposeSheet } from './TaskComposeSheet';
|
||||
import { VideoRecordingOverlay } from './VideoRecordingOverlay';
|
||||
import { AudioRecordingOverlay } from './AudioRecordingOverlay';
|
||||
import { ReviewSheet } from './ReviewSheet';
|
||||
@@ -48,6 +58,12 @@ interface ComposeDockProps {
|
||||
networkId: string;
|
||||
targetPath: ParticlePath;
|
||||
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>;
|
||||
submitText?: (content: string) => Promise<void>;
|
||||
/**
|
||||
@@ -63,6 +79,7 @@ export function ComposeDock({
|
||||
networkId,
|
||||
targetPath,
|
||||
silentPresence = false,
|
||||
allowTask = true,
|
||||
submitMedia,
|
||||
submitText: submitTextOverride,
|
||||
onParticleCreated,
|
||||
@@ -72,6 +89,7 @@ export function ComposeDock({
|
||||
const [mode, setMode] = useState<RecordingMode>('video');
|
||||
const [ui, setUi] = useState<ComposeUiState>({ kind: 'idle' });
|
||||
const [textOpen, setTextOpen] = useState(false);
|
||||
const [taskOpen, setTaskOpen] = useState(false);
|
||||
|
||||
const [camPerm, requestCamPerm] = useCameraPermissions();
|
||||
const [micPerm, requestMicPerm] = useMicrophonePermissions();
|
||||
@@ -79,13 +97,13 @@ export function ComposeDock({
|
||||
// Tell StreamView to fully unmount its expo-video player while we record.
|
||||
// That player otherwise holds the iOS AVAudioSession and crashes the camera.
|
||||
const setComposing = usePlaybackPauseStore((s) => s.setComposing);
|
||||
const isComposing = ui.kind !== 'idle' || textOpen;
|
||||
const isComposing = ui.kind !== 'idle' || textOpen || taskOpen;
|
||||
useEffect(() => {
|
||||
setComposing(isComposing);
|
||||
return () => setComposing(false);
|
||||
}, [isComposing, setComposing]);
|
||||
|
||||
useComposingBroadcast({ ui, textOpen, silent: silentPresence });
|
||||
useComposingBroadcast({ ui, textOpen, taskOpen, silent: silentPresence });
|
||||
|
||||
const ensurePermissions = useCallback(
|
||||
async (forVideo: boolean): Promise<boolean> => {
|
||||
@@ -187,6 +205,20 @@ export function ComposeDock({
|
||||
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 =
|
||||
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="flex-row items-center justify-between px-8 pb-10"
|
||||
className="flex-row items-center px-8 pb-10"
|
||||
>
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
setMode((m) => (m === 'video' ? 'audio' : 'video'))
|
||||
}
|
||||
disabled={ui.kind !== 'idle'}
|
||||
accessibilityLabel={`Switch to ${
|
||||
mode === 'video' ? 'audio' : 'video'
|
||||
} mode`}
|
||||
className={cn(
|
||||
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||
ui.kind !== 'idle' && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
{mode === 'video' ? (
|
||||
<VideoIcon color="white" size={20} strokeWidth={1.6} />
|
||||
) : (
|
||||
<Mic color="white" size={20} strokeWidth={1.6} />
|
||||
)}
|
||||
</Pressable>
|
||||
{/* Left and right clusters flex equally so the record button stays
|
||||
centered regardless of how many side controls are present. */}
|
||||
<View className="flex-1 flex-row items-center">
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
setMode((m) => (m === 'video' ? 'audio' : 'video'))
|
||||
}
|
||||
disabled={ui.kind !== 'idle'}
|
||||
accessibilityLabel={`Switch to ${
|
||||
mode === 'video' ? 'audio' : 'video'
|
||||
} mode`}
|
||||
className={cn(
|
||||
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||
ui.kind !== 'idle' && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
{mode === 'video' ? (
|
||||
<VideoIcon color="white" size={20} strokeWidth={1.6} />
|
||||
) : (
|
||||
<Mic color="white" size={20} strokeWidth={1.6} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View className="items-center">
|
||||
<Pressable
|
||||
@@ -230,17 +266,33 @@ export function ComposeDock({
|
||||
<Text className="text-white/60 mt-2 text-xs">Tap to record</Text>
|
||||
</View>
|
||||
|
||||
<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 className="flex-1 flex-row items-center justify-end gap-3">
|
||||
{allowTask ? (
|
||||
<Pressable
|
||||
onPress={() => setTaskOpen(true)}
|
||||
disabled={ui.kind !== 'idle'}
|
||||
accessibilityLabel="Create task"
|
||||
className={cn(
|
||||
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||
ui.kind !== 'idle' && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
) : null}
|
||||
@@ -277,6 +329,12 @@ export function ComposeDock({
|
||||
onClose={() => setTextOpen(false)}
|
||||
onSubmit={submitText}
|
||||
/>
|
||||
|
||||
<TaskComposeSheet
|
||||
open={taskOpen}
|
||||
onClose={() => setTaskOpen(false)}
|
||||
onSubmit={submitTask}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -284,17 +342,23 @@ export function ComposeDock({
|
||||
function useComposingBroadcast({
|
||||
ui,
|
||||
textOpen,
|
||||
taskOpen,
|
||||
silent,
|
||||
}: {
|
||||
ui: ComposeUiState;
|
||||
textOpen: boolean;
|
||||
taskOpen: boolean;
|
||||
silent: boolean;
|
||||
}) {
|
||||
// null when the dock is rendered outside a stream (no presence provider).
|
||||
const broadcast = useStreamComposingBroadcastOptional();
|
||||
|
||||
const mode: ComposingMode | null =
|
||||
ui.kind === 'recording' ? 'recording' : textOpen ? 'typing' : null;
|
||||
ui.kind === 'recording'
|
||||
? 'recording'
|
||||
: textOpen || taskOpen
|
||||
? 'typing'
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
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 { Alert, Dimensions, Pressable, Text, View } from 'react-native';
|
||||
import type { Human } from '@/api/types';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import {
|
||||
@@ -215,7 +216,6 @@ function Tile({
|
||||
const identity = tile.participant.identity;
|
||||
const display = resolveHumanDisplay(identity, humans);
|
||||
const name = tile.participant.name || display.displayName;
|
||||
const initials = display.initials;
|
||||
const isSpeaking = tile.participant.isSpeaking;
|
||||
const muted =
|
||||
tile.participant.getTrackPublication(Track.Source.Microphone)?.isMuted ??
|
||||
@@ -234,9 +234,7 @@ function Tile({
|
||||
<VideoTrack trackRef={tile} style={{ flex: 1 }} objectFit="cover" />
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<View className="h-16 w-16 items-center justify-center rounded-full bg-neutral-700">
|
||||
<Text className="text-white text-xl font-semibold">{initials}</Text>
|
||||
</View>
|
||||
<Avatar humanId={identity} humans={humans} size="lg" />
|
||||
</View>
|
||||
)}
|
||||
<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 & 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="Today’s 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 ? `Couldn’t 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">
|
||||
Couldn’t 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">
|
||||
You’ll 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>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SafeAreaProvider,
|
||||
SafeAreaView,
|
||||
} from 'react-native-safe-area-context';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||
@@ -26,7 +27,12 @@ interface DrawerProps {
|
||||
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
|
||||
// called — the values are mutated internally by the native driver.
|
||||
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 isSigningOut = useAuthStore((s) => s.isSigningOut);
|
||||
|
||||
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={open}
|
||||
@@ -85,11 +89,11 @@ export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
|
||||
>
|
||||
<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="bg-sidebar-accent h-10 w-10 items-center justify-center rounded-full">
|
||||
<Text className="text-sidebar-accent-foreground text-sm font-semibold">
|
||||
{initials}
|
||||
</Text>
|
||||
</View>
|
||||
<Avatar
|
||||
humanId={user?.id}
|
||||
humans={user ? [user] : undefined}
|
||||
size="md"
|
||||
/>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="text-sidebar-foreground text-base font-medium"
|
||||
@@ -114,6 +118,13 @@ export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
|
||||
onNavigateAccount();
|
||||
}}
|
||||
/>
|
||||
<DrawerRow
|
||||
label="Settings"
|
||||
onPress={() => {
|
||||
onClose();
|
||||
onNavigateSettings();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="border-sidebar-border border-t px-2 py-2">
|
||||
|
||||
@@ -8,22 +8,28 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
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 { useAcceptInvitation, useMyInvitations } from '@/hooks/use-invitations';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { toUserMessage } from '@/lib/errors';
|
||||
import type { RootStackScreenProps } from '@/navigation/types';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { FlowyLogo } from '@/components/FlowyLogo';
|
||||
import { ListSeparator } from '@/components/ListSeparator';
|
||||
import { Drawer } from './Drawer';
|
||||
import { CreateNetworkSheet } from './CreateNetworkSheet';
|
||||
|
||||
export function NetworkListScreen({
|
||||
navigation,
|
||||
}: RootStackScreenProps<'NetworkList'>) {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const { data, isLoading, refetch, error } = useNetworks();
|
||||
const { data: invitations, refetch: refetchInvitations } = useMyInvitations();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
|
||||
|
||||
// Local refreshing state — driving RefreshControl from react-query's
|
||||
// isRefetching can leave the native spinner visually stuck after the
|
||||
@@ -32,11 +38,13 @@ export function NetworkListScreen({
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await refetch();
|
||||
await Promise.all([refetch(), refetchInvitations()]);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [refetch]);
|
||||
}, [refetch, refetchInvitations]);
|
||||
|
||||
const pendingInvitations = invitations ?? [];
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||
@@ -44,14 +52,21 @@ export function NetworkListScreen({
|
||||
<Pressable
|
||||
onPress={() => setDrawerOpen(true)}
|
||||
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">
|
||||
{initials}
|
||||
</Text>
|
||||
<Avatar
|
||||
humanId={user?.id}
|
||||
humans={user ? [user] : undefined}
|
||||
size="sm"
|
||||
/>
|
||||
</Pressable>
|
||||
<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>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -67,16 +82,21 @@ export function NetworkListScreen({
|
||||
<Text className="text-foreground font-medium">Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : !data || data.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (!data || data.length === 0) && pendingInvitations.length === 0 ? (
|
||||
<EmptyState onCreate={() => setCreateOpen(true)} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={data}
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.id}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||
}
|
||||
ItemSeparatorComponent={ListSeparator}
|
||||
ListHeaderComponent={
|
||||
pendingInvitations.length > 0 ? (
|
||||
<InvitationsSection invitations={pendingInvitations} />
|
||||
) : null
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<NetworkCard
|
||||
network={item}
|
||||
@@ -94,10 +114,65 @@ export function NetworkListScreen({
|
||||
onNavigateAccount={() => navigation.navigate('Account')}
|
||||
onNavigateSettings={() => navigation.navigate('Settings')}
|
||||
/>
|
||||
|
||||
<CreateNetworkSheet
|
||||
open={createOpen}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={(networkId) =>
|
||||
navigation.navigate('StreamList', { networkId })
|
||||
}
|
||||
/>
|
||||
</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">
|
||||
You’ve 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({
|
||||
network,
|
||||
onPress,
|
||||
@@ -124,15 +199,23 @@ function NetworkCard({
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-6">
|
||||
<Text className="text-foreground text-lg font-medium text-center">
|
||||
You aren’t in any networks yet.
|
||||
</Text>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 * 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 { toUserMessage } from '@/lib/errors';
|
||||
import type { RootStackScreenProps } from '@/navigation/types';
|
||||
|
||||
export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
|
||||
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 (
|
||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||
@@ -18,7 +76,38 @@ export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
|
||||
<View className="w-8" />
|
||||
</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 ?? '—'} />
|
||||
</View>
|
||||
</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 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';
|
||||
|
||||
export function SettingsScreen({
|
||||
navigation,
|
||||
}: 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 (
|
||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||
<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>
|
||||
|
||||
<View className="flex-1 items-center justify-center px-6">
|
||||
<Text className="text-muted-foreground text-center">
|
||||
Theme, notifications, and account preferences land here later.
|
||||
</Text>
|
||||
<View className="px-4 py-4">
|
||||
<SettingsGroup title="Notifications">
|
||||
<View className="flex-row items-center justify-between px-4 py-3">
|
||||
<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>
|
||||
</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 { Text, View } from 'react-native';
|
||||
import {
|
||||
FileIcon,
|
||||
HelpCircle,
|
||||
ScrollText,
|
||||
BookOpen,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react-native';
|
||||
import { HelpCircle } from 'lucide-react-native';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
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;
|
||||
|
||||
interface FallbackParticleViewProps {
|
||||
@@ -26,6 +14,10 @@ interface FallbackParticleViewProps {
|
||||
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({
|
||||
particle,
|
||||
networkId,
|
||||
@@ -37,25 +29,8 @@ export function FallbackParticleView({
|
||||
particle.created_by_human_id,
|
||||
network?.humans,
|
||||
);
|
||||
const meta = TYPE_META[particle.type] ?? {
|
||||
icon: HelpCircle,
|
||||
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;
|
||||
}
|
||||
})();
|
||||
const Icon = HelpCircle;
|
||||
const title = particle.type === 'folder' ? particle.properties.name : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
@@ -70,7 +45,7 @@ export function FallbackParticleView({
|
||||
<Icon color="rgba(255,255,255,0.7)" size={22} strokeWidth={1.5} />
|
||||
<View className="flex-1">
|
||||
<Text className="text-white text-base font-semibold">
|
||||
{meta.label}
|
||||
{particle.type}
|
||||
</Text>
|
||||
{title ? (
|
||||
<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 * as Haptics from 'expo-haptics';
|
||||
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';
|
||||
|
||||
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
||||
@@ -77,7 +77,6 @@ export function ReactionStack({
|
||||
{activeTextKeys.map((text) => {
|
||||
const reactors = reactions?.[text] ?? [];
|
||||
const isMine = reactors.includes(currentHumanId);
|
||||
const firstReactor = resolveHumanDisplay(reactors[0], humans);
|
||||
return (
|
||||
<Pressable
|
||||
key={text}
|
||||
@@ -93,11 +92,7 @@ export function ReactionStack({
|
||||
: null,
|
||||
]}
|
||||
>
|
||||
<View className="bg-white/15 h-5 w-5 items-center justify-center rounded-full">
|
||||
<Text className="text-white text-[9px] font-semibold">
|
||||
{firstReactor.initials}
|
||||
</Text>
|
||||
</View>
|
||||
<Avatar humanId={reactors[0]} humans={humans} size="xs" />
|
||||
<Text className="text-white/90 text-xs" numberOfLines={1}>
|
||||
{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,
|
||||
} from './stream-presence-context';
|
||||
import { TextParticleView } from './TextParticleView';
|
||||
import { TaskParticleView } from './TaskParticleView';
|
||||
import { PaperParticleView } from './PaperParticleView';
|
||||
import { FileParticleView } from './FileParticleView';
|
||||
import { MediaParticleView } from './MediaParticleView';
|
||||
import { DeletedParticleView } from './DeletedParticleView';
|
||||
import { FallbackParticleView } from './FallbackParticleView';
|
||||
import { useExitCountdown } from './use-exit-countdown';
|
||||
import { StreamTopActions } from './StreamTopActions';
|
||||
import { StreamStatusPills } from './StreamStatusPills';
|
||||
import { StreamActionsSheet, type StreamActionId } from './StreamActionsSheet';
|
||||
import { StreamMembersSheet } from './StreamMembersSheet';
|
||||
import { RenameStreamSheet } from './RenameStreamSheet';
|
||||
@@ -456,6 +460,37 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
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:
|
||||
return (
|
||||
<FallbackParticleView
|
||||
@@ -544,31 +579,6 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
/>
|
||||
</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>
|
||||
</GestureDetector>
|
||||
|
||||
@@ -624,6 +634,20 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
</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
|
||||
centered on the canvas; outside the GestureDetector so each pill
|
||||
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 { Platform, ScrollView, Text, View, type ViewStyle } from 'react-native';
|
||||
import { Renderer, useMarkdown, type MarkedStyles } from 'react-native-marked';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { ScrollView, Text, View } from 'react-native';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MarkdownBody } from '@/components/MarkdownBody';
|
||||
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
|
||||
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({
|
||||
particle,
|
||||
paused,
|
||||
@@ -208,11 +58,6 @@ export function TextParticleView({
|
||||
const durationS = computeReadDuration(content);
|
||||
const elapsedRef = useRef(0);
|
||||
const safe = useStreamSafeArea();
|
||||
const markdownNodes = useMarkdown(withTaskCheckboxes(content), {
|
||||
renderer: MARKDOWN_RENDERER,
|
||||
theme: MARKDOWN_THEME,
|
||||
styles: MARKDOWN_STYLES,
|
||||
});
|
||||
|
||||
const editedLabel = editedAt ? (
|
||||
<View className="mt-3 items-center">
|
||||
@@ -290,7 +135,7 @@ export function TextParticleView({
|
||||
showsVerticalScrollIndicator
|
||||
indicatorStyle="white"
|
||||
>
|
||||
{markdownNodes}
|
||||
<MarkdownBody content={content} />
|
||||
{editedLabel}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
@@ -189,6 +189,7 @@ export function NewStreamScreen({
|
||||
networkId={networkId}
|
||||
targetPath={placeholderPath}
|
||||
silentPresence
|
||||
allowTask={false}
|
||||
submitMedia={submitMedia}
|
||||
submitText={submitText}
|
||||
/>
|
||||
|
||||
@@ -3,11 +3,12 @@ import { Pressable, Text, View } from 'react-native';
|
||||
import { Headphones } from 'lucide-react-native';
|
||||
import type { Particle, StreamProperties } from '@/api/types';
|
||||
import { isParticleDeleted } from '@/api/types';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
|
||||
import { useLiveLatestChild } from '@/hooks/use-particle';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { particlePath } from '@/lib/particle-path';
|
||||
import { cn, getInitials } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
interface StreamCardProps {
|
||||
@@ -35,34 +36,20 @@ export const StreamCard = memo(function StreamCard({
|
||||
particle.visible_to.length === 2 &&
|
||||
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) {
|
||||
const otherEntry = particle.visible_to.find(
|
||||
(v) => v !== `human:${userId}`,
|
||||
);
|
||||
if (otherEntry) {
|
||||
const otherId = otherEntry.replace('human:', '');
|
||||
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
||||
if (otherHuman) return getInitials(otherHuman.email);
|
||||
}
|
||||
if (otherEntry) return otherEntry.replace('human:', '');
|
||||
}
|
||||
return latestChild?.created_by_human_id ?? null;
|
||||
}, [isDM, particle.visible_to, userId, latestChild]);
|
||||
|
||||
if (latestChild) {
|
||||
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 fallbackInitials = particle.properties.name.slice(0, 2).toUpperCase();
|
||||
|
||||
const isUnseen = useMemo(() => {
|
||||
if (!latestChild) return false;
|
||||
@@ -87,7 +74,7 @@ export const StreamCard = memo(function StreamCard({
|
||||
return latestChild.properties.content;
|
||||
case 'file':
|
||||
return latestChild.properties.filename;
|
||||
case 'quest':
|
||||
case 'task':
|
||||
return latestChild.properties.title;
|
||||
case 'paper':
|
||||
return latestChild.properties.title;
|
||||
@@ -102,21 +89,12 @@ export const StreamCard = memo(function StreamCard({
|
||||
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"
|
||||
>
|
||||
<View
|
||||
className={cn(
|
||||
'h-10 w-10 items-center justify-center rounded-full',
|
||||
isUnseen ? 'bg-primary' : 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
className={cn(
|
||||
'text-xs font-semibold',
|
||||
isUnseen ? 'text-primary-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{initials}
|
||||
</Text>
|
||||
</View>
|
||||
<Avatar
|
||||
humanId={avatarHumanId}
|
||||
humans={network?.humans}
|
||||
size="md"
|
||||
fallbackInitials={fallbackInitials}
|
||||
/>
|
||||
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Settings as SettingsIcon } from 'lucide-react-native';
|
||||
import { ListSeparator } from '@/components/ListSeparator';
|
||||
import { toUserMessage } from '@/lib/errors';
|
||||
import { particlePath } from '@/lib/particle-path';
|
||||
@@ -32,6 +33,9 @@ export function StreamListScreen({
|
||||
<Header
|
||||
title={network?.name ?? 'Streams'}
|
||||
onBack={() => navigation.goBack()}
|
||||
onOpenSettings={() =>
|
||||
navigation.navigate('NetworkSettings', { networkId })
|
||||
}
|
||||
/>
|
||||
|
||||
{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 (
|
||||
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
||||
<Pressable
|
||||
@@ -83,7 +95,14 @@ function Header({ title, onBack }: { title: string; onBack: () => void }) {
|
||||
>
|
||||
{title}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -97,7 +97,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
||||
case 'media':
|
||||
case 'file':
|
||||
case 'text':
|
||||
case 'quest':
|
||||
case 'task':
|
||||
case 'paper': {
|
||||
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
|
||||
// particles carry `properties.edited_at`, so coerce it if present.
|
||||
|
||||
@@ -103,6 +103,32 @@ export async function createTextParticle({
|
||||
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 {
|
||||
if (mime === 'video/mp4') return '.mp4';
|
||||
if (mime === 'video/quicktime') return '.mov';
|
||||
|
||||
@@ -7,6 +7,7 @@ import { StreamListScreen } from '@/features/streams/StreamListScreen';
|
||||
import { NewStreamScreen } from '@/features/streams/NewStreamScreen';
|
||||
import { StreamViewScreen } from '@/features/stream-view/StreamViewScreen';
|
||||
import { HuddleScreen } from '@/features/huddle/HuddleScreen';
|
||||
import { NetworkSettingsScreen } from '@/features/network-settings/NetworkSettingsScreen';
|
||||
import { SettingsScreen } from '@/features/settings/SettingsScreen';
|
||||
import { AccountScreen } from '@/features/settings/AccountScreen';
|
||||
import type { RootStackParamList } from './types';
|
||||
@@ -54,6 +55,11 @@ export function RootNavigator() {
|
||||
component={NewStreamScreen}
|
||||
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="Account" component={AccountScreen} />
|
||||
</Stack.Navigator>
|
||||
|
||||
@@ -15,6 +15,7 @@ export type RootStackParamList = {
|
||||
serverUrl: string;
|
||||
};
|
||||
NewStream: { networkId: string };
|
||||
NetworkSettings: { networkId: string };
|
||||
Settings: undefined;
|
||||
Account: undefined;
|
||||
};
|
||||
|
||||
@@ -63,6 +63,8 @@ interface AuthState {
|
||||
requestCode: (email: string) => Promise<void>;
|
||||
signIn: (email: string, code: string) => 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
|
||||
* 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 () => {
|
||||
stopPushTokenSync();
|
||||
apiClient.setToken(null);
|
||||
|
||||
@@ -4278,6 +4278,18 @@ expo-haptics@~15.0.7:
|
||||
resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.8.tgz#f93f895ac5d76fe0c5ac26b3644e1dbb097833f3"
|
||||
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:
|
||||
version "15.0.8"
|
||||
resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz#911c5effeba9baff2ccde79ef0ff5bf856215f8d"
|
||||
|
||||
Reference in New Issue
Block a user