Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79824baaa6 | |||
| d60b948262 | |||
| fec7f5d6d8 | |||
| d14cec5885 | |||
| 3d80ac2993 | |||
| 428f9ea1c9 | |||
| f2f8602263 | |||
| 661577ecd2 | |||
| c6ce1bdc65 | |||
| bfe53b46cf | |||
| 129e4772dd | |||
| 92c12ad0bd | |||
| c11c5074ce | |||
| fa9f88f6f6 |
@@ -5,3 +5,4 @@ build/
|
|||||||
compile_commands.json
|
compile_commands.json
|
||||||
CMakeLists.txt.user
|
CMakeLists.txt.user
|
||||||
tags
|
tags
|
||||||
|
project.el
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ spec:
|
|||||||
name: shared-secrets
|
name: shared-secrets
|
||||||
key: STRIPE_WEBHOOK_SECRET
|
key: STRIPE_WEBHOOK_SECRET
|
||||||
- name: "STRIPE_PRICE_PRO_MONTHLY"
|
- name: "STRIPE_PRICE_PRO_MONTHLY"
|
||||||
value: "price_1TMBElJu6RWBXAm2pPthUoh6"
|
value: "price_1ToweOJu6RWBXAm2w2wTo8VL"
|
||||||
- name: "STRIPE_PRICE_PRO_ANNUAL"
|
- name: "STRIPE_PRICE_PRO_ANNUAL"
|
||||||
value: "price_1TMBElJu6RWBXAm2TOpzdXk5"
|
value: "price_1TowexJu6RWBXAm2G9M0WJBK"
|
||||||
- name: "BILLING_SUCCESS_URL"
|
- name: "BILLING_SUCCESS_URL"
|
||||||
value: "llink://billing/success"
|
value: "llink://billing/success"
|
||||||
- name: "BILLING_CANCEL_URL"
|
- name: "BILLING_CANCEL_URL"
|
||||||
|
|||||||
+17
-1
@@ -1,8 +1,24 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html class="dark">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>llink</title>
|
<title>llink</title>
|
||||||
|
<script>
|
||||||
|
// Apply the stored theme before first paint to avoid a flash. Mirrors
|
||||||
|
// the logic in stores/theme-store.ts; defaults to dark.
|
||||||
|
try {
|
||||||
|
var m = localStorage.getItem('llink:theme');
|
||||||
|
var dark =
|
||||||
|
m === 'light'
|
||||||
|
? false
|
||||||
|
: m === 'system'
|
||||||
|
? matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
: true;
|
||||||
|
if (dark) document.documentElement.classList.add('dark');
|
||||||
|
} catch (e) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "Flowy.llink",
|
"name": "Flowy.llink",
|
||||||
"productName": "Flowy.llink",
|
"productName": "Flowy.llink",
|
||||||
"version": "1.7.0",
|
"version": "1.9.0",
|
||||||
"description": "Flowy.llink is a video messaging app for teams",
|
"description": "Flowy.llink is a video messaging app for teams",
|
||||||
"main": ".vite/build/main.js",
|
"main": ".vite/build/main.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -23,12 +23,18 @@ import {
|
|||||||
import { SoundEffectsProvider } from '@/lib/sound-effects/sound-effects-provider';
|
import { SoundEffectsProvider } from '@/lib/sound-effects/sound-effects-provider';
|
||||||
import { platform } from '@/lib/platform';
|
import { platform } from '@/lib/platform';
|
||||||
import { InAppAutoplayCard } from '@/components/in-app-autoplay-card';
|
import { InAppAutoplayCard } from '@/components/in-app-autoplay-card';
|
||||||
|
import { useOnboardingStore } from '@/stores/onboarding-store';
|
||||||
|
import { OnboardingOverlay } from '@/features/onboarding/onboarding-overlay';
|
||||||
|
|
||||||
const queryClient = createQueryClient();
|
const queryClient = createQueryClient();
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const status = useAuthStore((s) => s.status);
|
const status = useAuthStore((s) => s.status);
|
||||||
const restoreSession = useAuthStore((s) => s.restoreSession);
|
const restoreSession = useAuthStore((s) => s.restoreSession);
|
||||||
|
const hasCompletedOnboarding = useOnboardingStore(
|
||||||
|
(s) => s.hasCompletedOnboarding,
|
||||||
|
);
|
||||||
|
const markOnboardingComplete = useOnboardingStore((s) => s.markComplete);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
restoreSession();
|
restoreSession();
|
||||||
@@ -46,6 +52,10 @@ const App = () => {
|
|||||||
return <LoginPage />;
|
return <LoginPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasCompletedOnboarding) {
|
||||||
|
return <OnboardingOverlay onComplete={markOnboardingComplete} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PusherProvider>
|
<PusherProvider>
|
||||||
<AuthenticatedApp />
|
<AuthenticatedApp />
|
||||||
|
|||||||
@@ -29,14 +29,14 @@ export function ComposingIndicator({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={u.humanId}
|
key={u.humanId}
|
||||||
className="flex rotate-180 items-center gap-1.5 rounded-full bg-white/10 px-2 py-1 backdrop-blur-sm"
|
className="bg-card/70 border-border flex rotate-180 items-center gap-1.5 rounded-full border px-2 py-1 backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<span className="flex gap-0.5">
|
<span className="flex gap-0.5">
|
||||||
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:0ms]" />
|
<span className="bg-muted-foreground size-1 animate-bounce rounded-full [animation-delay:0ms]" />
|
||||||
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:150ms]" />
|
<span className="bg-muted-foreground size-1 animate-bounce rounded-full [animation-delay:150ms]" />
|
||||||
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:300ms]" />
|
<span className="bg-muted-foreground size-1 animate-bounce rounded-full [animation-delay:300ms]" />
|
||||||
</span>
|
</span>
|
||||||
<span className="whitespace-nowrap text-[10px] text-white/50">
|
<span className="text-muted-foreground whitespace-nowrap text-[10px]">
|
||||||
{displayName} {modeLabel}
|
{displayName} {modeLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,23 +38,23 @@ export function ConfirmDestructiveOverlay({
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[100]">
|
<div className="fixed inset-0 z-[100]">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
className="absolute inset-0 bg-scrim/60 backdrop-blur-sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
<div className="bg-popover/95 text-popover-foreground border-border absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border p-6 shadow-2xl backdrop-blur-xl">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
|
<h2 className="text-sm font-semibold">{title}</h2>
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys="Esc"
|
keys="Esc"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close (or press Esc)"
|
title="Close (or press Esc)"
|
||||||
className="text-xs text-white/30"
|
className="text-muted-foreground text-xs"
|
||||||
>
|
>
|
||||||
to close
|
to close
|
||||||
</KeyHint>
|
</KeyHint>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-white/60">{description}</div>
|
<div className="text-muted-foreground text-sm">{description}</div>
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-end gap-2">
|
<div className="mt-5 flex items-center justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function HumanAvatar({
|
|||||||
const url = useAvatarUrl(avatarObjectId);
|
const url = useAvatarUrl(avatarObjectId);
|
||||||
return (
|
return (
|
||||||
<Avatar {...props}>
|
<Avatar {...props}>
|
||||||
{url && <AvatarImage src={url} alt={initials} />}
|
<AvatarImage src={url} alt={initials} />
|
||||||
<AvatarFallback className={fallbackClassName}>{initials}</AvatarFallback>
|
<AvatarFallback className={fallbackClassName}>{initials}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { Fragment } from 'react';
|
|||||||
import { Kbd } from '@/components/ui/kbd';
|
import { Kbd } from '@/components/ui/kbd';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
/** Dark-overlay chip restyle of the design-system Kbd, kept in one place. */
|
/** Chip restyle of the design-system Kbd, kept in one place. Tints to the
|
||||||
|
surrounding text color so it reads on themed panels and over media alike. */
|
||||||
const chipClass =
|
const chipClass =
|
||||||
'rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs font-normal text-current';
|
'rounded bg-current/10 px-1.5 py-0.5 font-mono text-xs font-normal text-current';
|
||||||
|
|
||||||
interface KeyHintProps {
|
interface KeyHintProps {
|
||||||
/** Key chip(s): "Esc" or ["Esc", "Q"]. */
|
/** Key chip(s): "Esc" or ["Esc", "Q"]. */
|
||||||
@@ -68,7 +69,7 @@ export function KeyHint({
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
title={title}
|
title={title}
|
||||||
className={cn(
|
className={cn(
|
||||||
'cursor-pointer rounded transition-colors hover:text-white/80',
|
'cursor-pointer rounded transition-opacity hover:opacity-80',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...rest}
|
{...rest}
|
||||||
|
|||||||
@@ -48,19 +48,19 @@ export function KeybindingsOverlay({
|
|||||||
<div className="fixed inset-0 z-[100]">
|
<div className="fixed inset-0 z-[100]">
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
className="absolute inset-0 bg-scrim/60 backdrop-blur-sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
{/* Panel */}
|
{/* Panel */}
|
||||||
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
<div className="bg-popover/95 text-popover-foreground border-border absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border p-6 shadow-2xl backdrop-blur-xl">
|
||||||
<div className="mb-5 flex items-center justify-between">
|
<div className="mb-5 flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
|
<h2 className="text-sm font-semibold">{title}</h2>
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys={['Esc', '?']}
|
keys={['Esc', '?']}
|
||||||
separator="or"
|
separator="or"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close (or press Esc / ?)"
|
title="Close (or press Esc / ?)"
|
||||||
className="text-xs text-white/30"
|
className="text-muted-foreground text-xs"
|
||||||
>
|
>
|
||||||
to close
|
to close
|
||||||
</KeyHint>
|
</KeyHint>
|
||||||
@@ -68,21 +68,19 @@ export function KeybindingsOverlay({
|
|||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<section key={group.label}>
|
<section key={group.label}>
|
||||||
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
<h3 className="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
|
||||||
{group.label}
|
{group.label}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex flex-col gap-1">
|
||||||
{group.bindings.map((binding) => (
|
{group.bindings.map((binding) => (
|
||||||
<li
|
<li
|
||||||
key={binding.description}
|
key={binding.description}
|
||||||
className="flex items-center justify-between border-b border-white/5 pb-1 last:border-0 last:pb-0"
|
className="border-border flex items-center justify-between border-b pb-1 last:border-0 last:pb-0"
|
||||||
>
|
>
|
||||||
<span className="text-sm text-white/70">
|
<span className="text-sm">{binding.description}</span>
|
||||||
{binding.description}
|
|
||||||
</span>
|
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys={binding.keys}
|
keys={binding.keys}
|
||||||
className="flex items-center gap-1 text-white/60"
|
className="text-muted-foreground flex items-center gap-1"
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md"
|
className="bg-card text-card-foreground border-border max-w-sm overflow-hidden rounded-2xl border"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleOpen(e);
|
handleOpen(e);
|
||||||
@@ -39,7 +39,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-1 p-3">
|
<div className="flex flex-col gap-1 p-3">
|
||||||
<div className="flex items-center gap-1.5 text-xs text-white/50">
|
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||||
{metadata.favicon ? (
|
{metadata.favicon ? (
|
||||||
<img
|
<img
|
||||||
src={metadata.favicon}
|
src={metadata.favicon}
|
||||||
@@ -57,12 +57,12 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
|||||||
<span className="truncate">{metadata.domain}</span>
|
<span className="truncate">{metadata.domain}</span>
|
||||||
</div>
|
</div>
|
||||||
{metadata.title && (
|
{metadata.title && (
|
||||||
<p className="truncate text-sm font-semibold leading-snug text-white">
|
<p className="truncate text-sm font-semibold leading-snug">
|
||||||
{metadata.title}
|
{metadata.title}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{metadata.description && (
|
{metadata.description && (
|
||||||
<p className="line-clamp-2 text-xs leading-relaxed text-white/70">
|
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||||
{metadata.description}
|
{metadata.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -71,7 +71,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="xs"
|
size="xs"
|
||||||
className="text-white/70 hover:bg-white/10 hover:text-white"
|
className="text-muted-foreground"
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
>
|
>
|
||||||
<ExternalLink data-icon="inline-start" />
|
<ExternalLink data-icon="inline-start" />
|
||||||
@@ -80,7 +80,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="xs"
|
size="xs"
|
||||||
className="text-white/70 hover:bg-white/10 hover:text-white"
|
className="text-muted-foreground"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
>
|
>
|
||||||
<Copy data-icon="inline-start" />
|
<Copy data-icon="inline-start" />
|
||||||
@@ -111,12 +111,12 @@ export function LinkPreviewCardFallback({ url }: { url: string }) {
|
|||||||
|
|
||||||
export function LinkPreviewCardSkeleton() {
|
export function LinkPreviewCardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md">
|
<div className="bg-card border-border max-w-sm overflow-hidden rounded-2xl border">
|
||||||
<Skeleton className="h-32 w-full rounded-none bg-white/5" />
|
<Skeleton className="h-32 w-full rounded-none" />
|
||||||
<div className="flex flex-col gap-2 p-3">
|
<div className="flex flex-col gap-2 p-3">
|
||||||
<Skeleton className="h-3 w-24 bg-white/10" />
|
<Skeleton className="h-3 w-24" />
|
||||||
<Skeleton className="h-4 w-48 bg-white/10" />
|
<Skeleton className="h-4 w-48" />
|
||||||
<Skeleton className="h-3 w-full bg-white/10" />
|
<Skeleton className="h-3 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ export function ScreenSourcePicker({
|
|||||||
const windows = sources.filter((s) => s.id.startsWith('window:'));
|
const windows = sources.filter((s) => s.id.startsWith('window:'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
|
<div className="bg-scrim/80 absolute inset-0 z-50 flex items-center justify-center">
|
||||||
<div className="mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-zinc-900 shadow-xl">
|
<div className="bg-popover text-popover-foreground mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg shadow-xl">
|
||||||
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
|
<div className="border-border flex items-center justify-between border-b px-5 py-4">
|
||||||
<h2 className="text-base font-medium text-zinc-100">{title}</h2>
|
<h2 className="text-base font-medium">{title}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="text-zinc-400 hover:text-zinc-200"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@@ -48,7 +48,7 @@ export function ScreenSourcePicker({
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-center text-sm text-zinc-400">
|
<p className="text-muted-foreground text-center text-sm">
|
||||||
Loading sources...
|
Loading sources...
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -73,17 +73,17 @@ export function ScreenSourcePicker({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 border-t border-zinc-700 px-5 py-3">
|
<div className="border-border flex justify-end gap-2 border-t px-5 py-3">
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="rounded-md px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-800"
|
className="hover:bg-accent text-muted-foreground rounded-md px-4 py-2 text-sm"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
disabled={!selectedId}
|
disabled={!selectedId}
|
||||||
onClick={() => selectedId && onSelect(selectedId)}
|
onClick={() => selectedId && onSelect(selectedId)}
|
||||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-40 disabled:hover:bg-blue-600"
|
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
@@ -106,7 +106,7 @@ function SourceSection({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-zinc-400">
|
<h3 className="text-muted-foreground mb-2 text-xs font-medium uppercase tracking-wide">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
@@ -116,8 +116,8 @@ function SourceSection({
|
|||||||
onClick={() => onSelect(source.id)}
|
onClick={() => onSelect(source.id)}
|
||||||
className={`overflow-hidden rounded-lg border-2 text-left transition-colors ${
|
className={`overflow-hidden rounded-lg border-2 text-left transition-colors ${
|
||||||
selectedId === source.id
|
selectedId === source.id
|
||||||
? 'border-blue-500 bg-zinc-800'
|
? 'border-primary bg-accent'
|
||||||
: 'border-transparent bg-zinc-800/50 hover:border-zinc-600'
|
: 'border-transparent bg-muted hover:border-border'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -125,7 +125,7 @@ function SourceSection({
|
|||||||
alt={source.name}
|
alt={source.name}
|
||||||
className="aspect-video w-full object-cover"
|
className="aspect-video w-full object-cover"
|
||||||
/>
|
/>
|
||||||
<p className="truncate px-2 py-1.5 text-xs text-zinc-300">
|
<p className="text-muted-foreground truncate px-2 py-1.5 text-xs">
|
||||||
{source.name}
|
{source.name}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function DialogOverlay({
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50',
|
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-scrim/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function ScrollArea({
|
|||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.Viewport
|
<ScrollAreaPrimitive.Viewport
|
||||||
data-slot="scroll-area-viewport"
|
data-slot="scroll-area-viewport"
|
||||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&>div]:!w-full"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollAreaPrimitive.Viewport>
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function Slider({
|
|||||||
<SliderPrimitive.Thumb
|
<SliderPrimitive.Thumb
|
||||||
data-slot="slider-thumb"
|
data-slot="slider-thumb"
|
||||||
key={index}
|
key={index}
|
||||||
className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
|
className="border-ring bg-background ring-ring/50 relative block size-3 shrink-0 select-none rounded-full border transition-[color,box-shadow] after:absolute after:-inset-2 hover:ring-3 focus-visible:outline-hidden focus-visible:ring-3 active:ring-3 disabled:pointer-events-none disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SliderPrimitive.Root>
|
</SliderPrimitive.Root>
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import {
|
|||||||
OctagonXIcon,
|
OctagonXIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useThemeStore } from '@/stores/theme-store';
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const isDark = useThemeStore((s) => s.isDark);
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
theme="dark"
|
theme={isDark ? 'dark' : 'light'}
|
||||||
className="toaster group"
|
className="toaster group"
|
||||||
icons={{
|
icons={{
|
||||||
success: <CircleCheckIcon className="size-4" />,
|
success: <CircleCheckIcon className="size-4" />,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function WindowControls() {
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => platform.window.minimize()}
|
onClick={() => platform.window.minimize()}
|
||||||
aria-label="Minimize"
|
aria-label="Minimize"
|
||||||
className="dark:hover:bg-white/10 rounded text-white/50"
|
className="text-muted-foreground rounded"
|
||||||
>
|
>
|
||||||
<Minus />
|
<Minus />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -34,7 +34,7 @@ export function WindowControls() {
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => platform.window.maximize()}
|
onClick={() => platform.window.maximize()}
|
||||||
aria-label={isMaximized ? 'Restore' : 'Maximize'}
|
aria-label={isMaximized ? 'Restore' : 'Maximize'}
|
||||||
className="dark:hover:bg-white/10 rounded text-white/50"
|
className="text-muted-foreground rounded"
|
||||||
>
|
>
|
||||||
{isMaximized ? <Copy /> : <Square />}
|
{isMaximized ? <Copy /> : <Square />}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -47,7 +47,7 @@ export function WindowControls() {
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
onClick={() => platform.window.close()}
|
onClick={() => platform.window.close()}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
className="hover:text-destructive dark:hover:bg-white/10 rounded text-white/50"
|
className="hover:text-destructive text-muted-foreground rounded"
|
||||||
>
|
>
|
||||||
<X />
|
<X />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export function AttachmentLightbox({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogPrimitive.Portal>
|
<DialogPrimitive.Portal>
|
||||||
<DialogPrimitive.Overlay className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-black/90 backdrop-blur-sm duration-100" />
|
<DialogPrimitive.Overlay className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-scrim/90 fixed inset-0 z-50 backdrop-blur-sm duration-100" />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
aria-describedby={undefined}
|
aria-describedby={undefined}
|
||||||
className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 flex items-center justify-center p-16 outline-none duration-100"
|
className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 flex items-center justify-center p-16 outline-none duration-100"
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function AttachmentThumbnail({
|
|||||||
tabIndex={previewable ? 0 : undefined}
|
tabIndex={previewable ? 0 : undefined}
|
||||||
onClick={previewable && onPreview ? onPreview : undefined}
|
onClick={previewable && onPreview ? onPreview : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10',
|
'bg-muted group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg',
|
||||||
previewable && 'cursor-pointer',
|
previewable && 'cursor-pointer',
|
||||||
isError && 'ring-1 ring-red-400/50',
|
isError && 'ring-1 ring-red-400/50',
|
||||||
)}
|
)}
|
||||||
@@ -75,19 +75,19 @@ function AttachmentThumbnail({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-0.5 px-1">
|
<div className="flex flex-col items-center gap-0.5 px-1">
|
||||||
<FileIcon className="size-5 text-white/60" />
|
<FileIcon className="text-muted-foreground size-5" />
|
||||||
<span className="max-w-full truncate text-[9px] text-white/50">
|
<span className="text-muted-foreground max-w-full truncate text-[9px]">
|
||||||
{attachment.file.name}
|
{attachment.file.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] text-white/40">
|
<span className="text-muted-foreground text-[9px]">
|
||||||
{formatFileSize(attachment.file.size)}
|
{formatFileSize(attachment.file.size)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isUploading && (
|
{isUploading && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
<div className="bg-scrim/50 absolute inset-0 flex items-center justify-center">
|
||||||
<Loader2 className="size-4 animate-spin text-white/70" />
|
<Loader2 className="size-4 animate-spin text-white" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ function AttachmentThumbnail({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemove();
|
onRemove();
|
||||||
}}
|
}}
|
||||||
className="absolute right-0.5 top-0.5 hidden rounded-full bg-black/70 p-0.5 text-white/70 hover:text-white group-hover:block"
|
className="bg-scrim/70 absolute right-0.5 top-0.5 hidden rounded-full p-0.5 text-white/80 hover:text-white group-hover:block"
|
||||||
>
|
>
|
||||||
<X className="size-3" />
|
<X className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -108,9 +108,9 @@ function AttachmentThumbnail({
|
|||||||
function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
|
function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
|
||||||
if (entry.isLoading) {
|
if (entry.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-16 w-28 shrink-0 flex-col gap-1.5 rounded-lg bg-white/10 p-2">
|
<div className="bg-muted flex h-16 w-28 shrink-0 flex-col gap-1.5 rounded-lg p-2">
|
||||||
<Skeleton className="h-2 w-16 bg-white/10" />
|
<Skeleton className="h-2 w-16" />
|
||||||
<Skeleton className="h-3 w-24 bg-white/10" />
|
<Skeleton className="h-3 w-24" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -124,9 +124,9 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => platform.link.openExternal(metadata?.url ?? entry.url)}
|
onClick={() => platform.link.openExternal(metadata?.url ?? entry.url)}
|
||||||
className="flex h-16 w-28 shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-lg bg-white/10 px-2 py-1.5 text-left transition-colors hover:bg-white/15"
|
className="bg-muted hover:bg-accent flex h-16 w-28 shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-lg px-2 py-1.5 text-left transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1 text-[10px] text-white/40">
|
<div className="text-muted-foreground flex items-center gap-1 text-[10px]">
|
||||||
{metadata?.favicon ? (
|
{metadata?.favicon ? (
|
||||||
<img
|
<img
|
||||||
src={metadata.favicon}
|
src={metadata.favicon}
|
||||||
@@ -142,7 +142,7 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
|
|||||||
<span className="truncate">{domain}</span>
|
<span className="truncate">{domain}</span>
|
||||||
</div>
|
</div>
|
||||||
{title && (
|
{title && (
|
||||||
<p className="line-clamp-2 text-[11px] font-medium leading-tight text-white/80">
|
<p className="text-foreground line-clamp-2 text-[11px] font-medium leading-tight">
|
||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -197,7 +197,7 @@ export function AttachmentStrip({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onAddClick();
|
onAddClick();
|
||||||
}}
|
}}
|
||||||
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-lg border border-dashed border-white/20 text-white/40 transition-colors hover:border-white/40 hover:text-white/60"
|
className="border-border text-muted-foreground hover:border-foreground/40 hover:text-foreground flex h-16 w-16 shrink-0 items-center justify-center rounded-lg border border-dashed transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ import { useMediaDevices } from '@/hooks/use-media-devices';
|
|||||||
import { resolveEffectiveDeviceId } from '@/hooks/use-effective-device-id';
|
import { resolveEffectiveDeviceId } from '@/hooks/use-effective-device-id';
|
||||||
import { useFileInput } from '@/hooks/use-file-input';
|
import { useFileInput } from '@/hooks/use-file-input';
|
||||||
import { createImageThumbnail } from '@/lib/image-thumbnail';
|
import { createImageThumbnail } from '@/lib/image-thumbnail';
|
||||||
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from '@/lib/constants';
|
import {
|
||||||
|
HOLD_THRESHOLD_MS,
|
||||||
|
MAX_ATTACHMENT_SIZE_BYTES,
|
||||||
|
MAX_ATTACHMENTS,
|
||||||
|
} from '@/lib/constants';
|
||||||
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
||||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||||||
@@ -59,8 +63,6 @@ interface ComposeOverlayProps {
|
|||||||
onParticleCreated?: (particleId: string) => void;
|
onParticleCreated?: (particleId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOLD_THRESHOLD_MS = 250;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Self-contained compose overlay. Each consumer renders its own instance
|
* Self-contained compose overlay. Each consumer renders its own instance
|
||||||
* with props that determine the mode (new stream vs. reply).
|
* with props that determine the mode (new stream vs. reply).
|
||||||
@@ -595,6 +597,16 @@ export function ComposeOverlay({
|
|||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
const currentStep = stepRef.current;
|
const currentStep = stepRef.current;
|
||||||
|
|
||||||
|
// Consume a key compose handles so it never reaches the stream's
|
||||||
|
// window-level navigation/action handlers. Without this, e.g. Escape
|
||||||
|
// while recording or reviewing would both cancel compose and exit the
|
||||||
|
// stream. Listening in the capture phase (see below) guarantees compose
|
||||||
|
// sees the key before those handlers regardless of registration order.
|
||||||
|
const consume = () => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
currentStep === 'typing' ||
|
currentStep === 'typing' ||
|
||||||
currentStep === 'task' ||
|
currentStep === 'task' ||
|
||||||
@@ -602,7 +614,7 @@ export function ComposeOverlay({
|
|||||||
currentStep === 'picking'
|
currentStep === 'picking'
|
||||||
) {
|
) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
consume();
|
||||||
cancel();
|
cancel();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -620,19 +632,19 @@ export function ComposeOverlay({
|
|||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
case 'idle': {
|
case 'idle': {
|
||||||
if (e.key === '`' && !e.repeat) {
|
if (e.key === '`' && !e.repeat) {
|
||||||
e.preventDefault();
|
consume();
|
||||||
handleRecordIntent();
|
handleRecordIntent();
|
||||||
} else if (e.key === 's' || e.key === 'S') {
|
} else if (e.key === 's' || e.key === 'S') {
|
||||||
e.preventDefault();
|
consume();
|
||||||
if (!guardIdle()) break;
|
if (!guardIdle()) break;
|
||||||
if (!requireDesktop('Screen recording')) break;
|
if (!requireDesktop('Screen recording')) break;
|
||||||
setRecordingSource('screen');
|
setRecordingSource('screen');
|
||||||
setStepSync('picking');
|
setStepSync('picking');
|
||||||
} else if (e.key === 't' || e.key === 'T') {
|
} else if (e.key === 't' || e.key === 'T') {
|
||||||
e.preventDefault();
|
consume();
|
||||||
handleTextIntent();
|
handleTextIntent();
|
||||||
} else if (e.key === 'd' || e.key === 'D') {
|
} else if (e.key === 'd' || e.key === 'D') {
|
||||||
e.preventDefault();
|
consume();
|
||||||
handleTaskIntent();
|
handleTaskIntent();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -641,17 +653,17 @@ export function ComposeOverlay({
|
|||||||
case 'recording': {
|
case 'recording': {
|
||||||
if (e.key === '`' && !e.repeat) {
|
if (e.key === '`' && !e.repeat) {
|
||||||
// Second tap stops media recording (toggle mode)
|
// Second tap stops media recording (toggle mode)
|
||||||
e.preventDefault();
|
consume();
|
||||||
handleStopIntent();
|
handleStopIntent();
|
||||||
} else if (
|
} else if (
|
||||||
(e.key === 's' || e.key === 'S') &&
|
(e.key === 's' || e.key === 'S') &&
|
||||||
recordingSourceRef.current === 'screen'
|
recordingSourceRef.current === 'screen'
|
||||||
) {
|
) {
|
||||||
// S stops screen recording when main window is focused
|
// S stops screen recording when main window is focused
|
||||||
e.preventDefault();
|
consume();
|
||||||
handleStopIntent();
|
handleStopIntent();
|
||||||
} else if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
|
} else if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
|
||||||
e.preventDefault();
|
consume();
|
||||||
handleCancelIntent();
|
handleCancelIntent();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -659,10 +671,10 @@ export function ComposeOverlay({
|
|||||||
|
|
||||||
case 'reviewing': {
|
case 'reviewing': {
|
||||||
if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
|
if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
|
||||||
e.preventDefault();
|
consume();
|
||||||
handleCancelIntent();
|
handleCancelIntent();
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
consume();
|
||||||
handleSendIntent();
|
handleSendIntent();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -689,10 +701,13 @@ export function ComposeOverlay({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
// Capture phase so compose can consume keys before the stream's
|
||||||
|
// window-level navigation/action handlers (which listen in the bubble
|
||||||
|
// phase) see them.
|
||||||
|
window.addEventListener('keydown', handleKeyDown, true);
|
||||||
window.addEventListener('keyup', handleKeyUp);
|
window.addEventListener('keyup', handleKeyUp);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown, true);
|
||||||
window.removeEventListener('keyup', handleKeyUp);
|
window.removeEventListener('keyup', handleKeyUp);
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
@@ -755,7 +770,7 @@ export function ComposeOverlay({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{step === 'recording' && recordingSource === 'screen' && (
|
{step === 'recording' && recordingSource === 'screen' && (
|
||||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
|
<div className="bg-scrim/90 absolute inset-0 z-50 flex flex-col items-center justify-center">
|
||||||
<div className="absolute top-8 z-10">
|
<div className="absolute top-8 z-10">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
|
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
|
||||||
@@ -827,8 +842,8 @@ export function ComposeOverlay({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{step === 'submitting' && (
|
{step === 'submitting' && (
|
||||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
|
<div className="bg-background/95 absolute inset-0 z-50 flex flex-col items-center justify-center backdrop-blur-sm">
|
||||||
<span className="animate-pulse text-sm text-white/60">
|
<span className="text-muted-foreground animate-pulse text-sm">
|
||||||
Sending...
|
Sending...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,13 +77,13 @@ export function ConfigureContainerStep({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16"
|
className="bg-background/95 absolute inset-0 z-50 flex flex-col pt-16 backdrop-blur-sm"
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
|
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1 text-xs text-white/50">
|
<Label className="text-muted-foreground mb-1 text-xs">
|
||||||
{kind === 'folder' ? 'Folder name' : 'Stream name'}
|
{kind === 'folder' ? 'Folder name' : 'Stream name'}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -94,21 +94,22 @@ export function ConfigureContainerStep({
|
|||||||
setName(e.target.value);
|
setName(e.target.value);
|
||||||
}}
|
}}
|
||||||
placeholder="Give it a name..."
|
placeholder="Give it a name..."
|
||||||
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Visibility */}
|
{/* Visibility */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1 text-xs text-white/50">Visible to</Label>
|
<Label className="text-muted-foreground mb-1 text-xs">
|
||||||
<div className="rounded-md border border-white/10">
|
Visible to
|
||||||
|
</Label>
|
||||||
|
<div className="border-border rounded-md border">
|
||||||
{/* Everyone in network */}
|
{/* Everyone in network */}
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
onClick={() => setEveryone((prev) => !prev)}
|
onClick={() => setEveryone((prev) => !prev)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors',
|
'flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors',
|
||||||
'text-white/70 hover:bg-white/5',
|
'hover:bg-accent',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -137,7 +138,7 @@ export function ConfigureContainerStep({
|
|||||||
onClick={() => toggleMember(member.id)}
|
onClick={() => toggleMember(member.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors',
|
'flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors',
|
||||||
'text-white/70 hover:bg-white/5',
|
'hover:bg-accent',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -145,7 +146,7 @@ export function ConfigureContainerStep({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="pointer-events-none"
|
className="pointer-events-none"
|
||||||
/>
|
/>
|
||||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/10 text-[10px] font-medium">
|
<span className="bg-muted flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-medium">
|
||||||
{initials}
|
{initials}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 truncate">
|
<span className="flex-1 truncate">
|
||||||
@@ -162,7 +163,7 @@ export function ConfigureContainerStep({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Keyboard hints */}
|
{/* Keyboard hints */}
|
||||||
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
|
<div className="text-muted-foreground absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm">
|
||||||
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
|
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
|
||||||
cancel
|
cancel
|
||||||
</KeyHint>
|
</KeyHint>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
/* Theme Milkdown Crepe to blend into the app's translucent "glass" surfaces.
|
/* Theme Milkdown Crepe to the app's design tokens so it follows light/dark.
|
||||||
* Crepe's frame-dark theme is a grayscale palette driven by --crepe-* custom
|
* Crepe's theme is a palette driven by --crepe-* custom properties; we map
|
||||||
* properties; we override those to white-on-transparent with a blue accent so
|
* those onto our CSS variables (globals.css) so the editor (and the read-only
|
||||||
* the editor (and the read-only display, which uses the same engine) sits on
|
* display, which uses the same engine) inherits the active theme and sits on
|
||||||
* top of the existing card instead of painting its own opaque background.
|
* top of the existing card instead of painting its own opaque background.
|
||||||
*
|
*
|
||||||
* The floating menus (slash menu, selection toolbar, link tooltip) are portaled
|
* The floating menus (slash menu, selection toolbar, link tooltip) are portaled
|
||||||
* to <body>, OUTSIDE .milkdown — so the variables must be declared on those
|
* to <body>, OUTSIDE .milkdown — so the variables must be declared on those
|
||||||
* selectors too, otherwise they fall back to transparent and the menu is
|
* selectors too, otherwise they fall back to transparent and the menu is
|
||||||
* unreadable over the content behind it. */
|
* unreadable over the content behind it. Our tokens resolve against the
|
||||||
|
* :root/.dark cascade on <html>, so the portaled menus stay themed too. */
|
||||||
.llink-crepe .milkdown,
|
.llink-crepe .milkdown,
|
||||||
.milkdown-slash-menu,
|
.milkdown-slash-menu,
|
||||||
.milkdown-toolbar,
|
.milkdown-toolbar,
|
||||||
@@ -15,24 +16,30 @@
|
|||||||
.milkdown-link-preview,
|
.milkdown-link-preview,
|
||||||
.milkdown-block-handle {
|
.milkdown-block-handle {
|
||||||
--crepe-color-background: transparent;
|
--crepe-color-background: transparent;
|
||||||
--crepe-color-on-background: rgb(255 255 255 / 0.92);
|
--crepe-color-on-background: var(--card-foreground);
|
||||||
/* Surface backs code blocks, tables, and the floating menus — keep it
|
/* Surface backs code blocks, tables, and the floating menus — use an opaque
|
||||||
* (near-)opaque so menus read clearly over content. */
|
* themed surface so menus read clearly over content. */
|
||||||
--crepe-color-surface: rgb(24 24 28 / 0.96);
|
--crepe-color-surface: var(--popover);
|
||||||
--crepe-color-surface-low: rgb(42 42 50 / 0.96);
|
--crepe-color-surface-low: var(--muted);
|
||||||
--crepe-color-on-surface: #ffffff;
|
--crepe-color-on-surface: var(--popover-foreground);
|
||||||
--crepe-color-on-surface-variant: rgb(255 255 255 / 0.65);
|
--crepe-color-on-surface-variant: var(--muted-foreground);
|
||||||
--crepe-color-outline: rgb(255 255 255 / 0.2);
|
--crepe-color-outline: var(--border);
|
||||||
--crepe-color-primary: #60a5fa;
|
--crepe-color-primary: var(--primary);
|
||||||
--crepe-color-secondary: rgb(96 165 250 / 0.25);
|
--crepe-color-secondary: color-mix(in oklch, var(--primary) 22%, transparent);
|
||||||
--crepe-color-on-secondary: #ffffff;
|
--crepe-color-on-secondary: var(--foreground);
|
||||||
--crepe-color-inverse: #ffffff;
|
--crepe-color-inverse: var(--foreground);
|
||||||
--crepe-color-on-inverse: #0b0b0b;
|
--crepe-color-on-inverse: var(--background);
|
||||||
--crepe-color-inline-code: #fca5a5;
|
--crepe-color-inline-code: var(--primary);
|
||||||
--crepe-color-inline-area: rgb(255 255 255 / 0.1);
|
--crepe-color-inline-area: var(--muted);
|
||||||
--crepe-color-error: #f87171;
|
--crepe-color-error: var(--destructive);
|
||||||
--crepe-color-hover: rgb(255 255 255 / 0.08);
|
--crepe-color-hover: var(--accent);
|
||||||
--crepe-color-selected: rgb(96 165 250 / 0.25);
|
--crepe-color-selected: color-mix(in oklch, var(--primary) 22%, transparent);
|
||||||
|
/* frame-dark ships white "glow" shadows that vanish on light surfaces;
|
||||||
|
* use a conventional dark drop shadow so floating menus lift in both themes. */
|
||||||
|
--crepe-shadow-1:
|
||||||
|
0px 1px 2px 0px rgb(0 0 0 / 0.12), 0px 1px 3px 1px rgb(0 0 0 / 0.08);
|
||||||
|
--crepe-shadow-2:
|
||||||
|
0px 1px 2px 0px rgb(0 0 0 / 0.16), 0px 2px 6px 2px rgb(0 0 0 / 0.12);
|
||||||
/* Use the application font, not Crepe's bundled Noto Sans / Noto Serif. */
|
/* Use the application font, not Crepe's bundled Noto Sans / Noto Serif. */
|
||||||
--crepe-font-default: inherit;
|
--crepe-font-default: inherit;
|
||||||
--crepe-font-title: inherit;
|
--crepe-font-title: inherit;
|
||||||
@@ -57,7 +64,7 @@
|
|||||||
.llink-crepe .milkdown .ProseMirror {
|
.llink-crepe .milkdown .ProseMirror {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
caret-color: white;
|
caret-color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Crepe's default heading scale (42px h1) is sized for a document editor;
|
/* Crepe's default heading scale (42px h1) is sized for a document editor;
|
||||||
@@ -103,33 +110,19 @@
|
|||||||
line-height: 1.375rem;
|
line-height: 1.375rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
color: rgb(255 255 255 / 0.7);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.llink-crepe .milkdown .ProseMirror > :first-child {
|
.llink-crepe .milkdown .ProseMirror > :first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Images come from URLs only (no stable public upload URL), so hide the
|
/* Image *links* render through the base commonmark schema (the ImageBlock
|
||||||
* file uploader — the placeholder then just prompts for a link. */
|
* feature is off — see markdown-editor.tsx). Keep them within the content
|
||||||
.llink-crepe
|
* column and softly rounded. */
|
||||||
.milkdown
|
.llink-crepe .milkdown .ProseMirror img {
|
||||||
:is(.milkdown-image-block, .milkdown-image-inline)
|
max-width: 100%;
|
||||||
.placeholder
|
max-height: 420px;
|
||||||
.uploader {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Read-only renders the image node view with inert editing chrome — hide it. */
|
|
||||||
.llink-crepe:not(.llink-crepe--fill) .milkdown .milkdown-image-block .operation,
|
|
||||||
.llink-crepe:not(.llink-crepe--fill)
|
|
||||||
.milkdown
|
|
||||||
.milkdown-image-block
|
|
||||||
.image-resize-handle {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.llink-crepe .milkdown .milkdown-image-block img {
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Crepe } from '@milkdown/crepe';
|
import { Crepe } from '@milkdown/crepe';
|
||||||
import { editorViewCtx } from '@milkdown/kit/core';
|
import { editorViewCtx, editorViewOptionsCtx } from '@milkdown/kit/core';
|
||||||
import { Selection } from '@milkdown/kit/prose/state';
|
import { Selection } from '@milkdown/kit/prose/state';
|
||||||
import '@milkdown/crepe/theme/common/style.css';
|
import '@milkdown/crepe/theme/common/style.css';
|
||||||
import '@milkdown/crepe/theme/frame-dark.css';
|
import '@milkdown/crepe/theme/frame-dark.css';
|
||||||
import './markdown-editor.css';
|
import './markdown-editor.css';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { transferFiles } from '@/lib/data-transfer';
|
||||||
|
|
||||||
interface MarkdownEditorProps {
|
interface MarkdownEditorProps {
|
||||||
/** Initial markdown. The editor owns its content after mount; edits flow out
|
/** Initial markdown. The editor owns its content after mount; edits flow out
|
||||||
@@ -57,23 +58,18 @@ export function MarkdownEditor({
|
|||||||
[Crepe.Feature.BlockEdit]: !readOnly,
|
[Crepe.Feature.BlockEdit]: !readOnly,
|
||||||
[Crepe.Feature.Toolbar]: !readOnly,
|
[Crepe.Feature.Toolbar]: !readOnly,
|
||||||
[Crepe.Feature.Placeholder]: !readOnly,
|
[Crepe.Feature.Placeholder]: !readOnly,
|
||||||
[Crepe.Feature.ImageBlock]: true,
|
// Off on purpose: file uploads aren't supported, so this feature's
|
||||||
|
// paste/drop handler would only strand an "upload in progress" node in
|
||||||
|
// the editor. Image *links* still render through the base commonmark
|
||||||
|
// schema, and pasted image files are routed to the compose attachment
|
||||||
|
// strip (see use-file-input).
|
||||||
|
[Crepe.Feature.ImageBlock]: false,
|
||||||
[Crepe.Feature.Latex]: false,
|
[Crepe.Feature.Latex]: false,
|
||||||
[Crepe.Feature.TopBar]: false,
|
[Crepe.Feature.TopBar]: false,
|
||||||
[Crepe.Feature.AI]: false,
|
[Crepe.Feature.AI]: false,
|
||||||
},
|
},
|
||||||
featureConfigs: {
|
featureConfigs: {
|
||||||
[Crepe.Feature.Placeholder]: { text: placeholder ?? '' },
|
[Crepe.Feature.Placeholder]: { text: placeholder ?? '' },
|
||||||
// Images come from URLs only (e.g. pasted markdown) — there is no
|
|
||||||
// stable public upload URL, so the file uploader is hidden in CSS and
|
|
||||||
// onUpload rejects in case a file ever reaches it anyway (the default
|
|
||||||
// would serialize an ephemeral blob: URL into the message).
|
|
||||||
[Crepe.Feature.ImageBlock]: {
|
|
||||||
blockUploadPlaceholderText: 'Paste an image link…',
|
|
||||||
inlineUploadPlaceholderText: 'paste an image link',
|
|
||||||
maxHeight: 420,
|
|
||||||
onUpload: () => Promise.reject(new Error('Image uploads disabled')),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,6 +81,21 @@ export function MarkdownEditor({
|
|||||||
onChangeRef.current?.(markdown);
|
onChangeRef.current?.(markdown);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Decline dropped files: they belong in the compose attachment strip
|
||||||
|
// (the drop zone still receives them), not inlined into the document.
|
||||||
|
// Without this the editor parses the drag's HTML into an image node with
|
||||||
|
// an ephemeral blob:/localhost src, which then leaks into the markdown.
|
||||||
|
crepe.editor.config((ctx) => {
|
||||||
|
ctx.update(editorViewOptionsCtx, (prev) => ({
|
||||||
|
...prev,
|
||||||
|
handleDrop: (view, event, slice, moved) => {
|
||||||
|
const data = event.dataTransfer;
|
||||||
|
if (data && transferFiles(data).length > 0) return true;
|
||||||
|
return prev.handleDrop?.(view, event, slice, moved) ?? false;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
crepe.create().then(() => {
|
crepe.create().then(() => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Paperclip } from 'lucide-react';
|
import { Paperclip } from 'lucide-react';
|
||||||
import type { RecordingMode } from '@/stores/media-settings-store';
|
import type { RecordingMode } from '@/stores/media-settings-store';
|
||||||
import { CenteredWaveform } from '@/components/audio/centered-waveform';
|
import { CenteredWaveform } from '@/components/audio/centered-waveform';
|
||||||
@@ -7,6 +8,7 @@ import { useObjectUrl } from '@/hooks/use-object-url';
|
|||||||
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
||||||
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { RECORDING_MAX_DURATION_SECONDS } from '@/lib/constants';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { KeyHint } from '@/components/key-hint';
|
import { KeyHint } from '@/components/key-hint';
|
||||||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||||||
@@ -34,16 +36,41 @@ interface RecordingOverlayProps {
|
|||||||
objectFit?: 'cover' | 'contain';
|
objectFit?: 'cover' | 'contain';
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecordingTimer() {
|
function useRecordingCountdown(active: boolean) {
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
if (!active) return;
|
||||||
setElapsed((prev) => prev + 1);
|
|
||||||
}, 1000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
let toastSent = false;
|
||||||
|
const start = Date.now();
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const seconds = Math.floor((Date.now() - start) / 1000);
|
||||||
|
setElapsed(seconds);
|
||||||
|
|
||||||
|
if (seconds >= RECORDING_MAX_DURATION_SECONDS && !toastSent) {
|
||||||
|
toast.warning(
|
||||||
|
'That is a long recording, cancel and re-record with your distilled thoughts',
|
||||||
|
);
|
||||||
|
toastSent = true;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
setElapsed(0);
|
||||||
|
};
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
return { elapsed, isWarning: elapsed >= RECORDING_MAX_DURATION_SECONDS };
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordingTimer({
|
||||||
|
elapsed,
|
||||||
|
isWarning,
|
||||||
|
}: {
|
||||||
|
elapsed: number;
|
||||||
|
isWarning: boolean;
|
||||||
|
}) {
|
||||||
const minutes = Math.floor(elapsed / 60);
|
const minutes = Math.floor(elapsed / 60);
|
||||||
const seconds = elapsed % 60;
|
const seconds = elapsed % 60;
|
||||||
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
@@ -51,7 +78,14 @@ function RecordingTimer() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
|
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
|
||||||
<span className="font-mono text-sm text-white/80">{display}</span>
|
<span
|
||||||
|
className={cn(
|
||||||
|
'font-mono text-sm text-white/80',
|
||||||
|
isWarning && 'text-red-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{display}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -137,14 +171,31 @@ export function RecordingOverlay({
|
|||||||
const isLoading = isRecording && !mediaStream;
|
const isLoading = isRecording && !mediaStream;
|
||||||
const requestIntent = useComposeIntentStore((s) => s.request);
|
const requestIntent = useComposeIntentStore((s) => s.request);
|
||||||
|
|
||||||
|
const { elapsed, isWarning } = useRecordingCountdown(
|
||||||
|
isRecording && !isLoading,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90',
|
'bg-scrim/90 absolute inset-0 z-50 flex flex-col items-center justify-center',
|
||||||
isReviewing && isDragging && 'ring-2 ring-inset ring-white/30',
|
isReviewing && isDragging && 'ring-2 ring-inset ring-white/30',
|
||||||
|
isRecording && isWarning && 'record-warning-glow',
|
||||||
)}
|
)}
|
||||||
{...(isReviewing ? dropZoneProps : {})}
|
{...(isReviewing ? dropZoneProps : {})}
|
||||||
>
|
>
|
||||||
|
{/* Top progress bar: fills over the recording duration, red in warning */}
|
||||||
|
{isRecording && !isLoading && (
|
||||||
|
<div className="absolute inset-x-0 top-0 z-20 h-1 bg-white/10">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full w-full origin-left record-progress',
|
||||||
|
isWarning ? 'bg-red-500' : 'bg-white/80',
|
||||||
|
)}
|
||||||
|
style={{ animationDuration: `${RECORDING_MAX_DURATION_SECONDS}s` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Loading state */}
|
{/* Loading state */}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="z-10 flex flex-col items-center gap-2">
|
<div className="z-10 flex flex-col items-center gap-2">
|
||||||
@@ -179,13 +230,13 @@ export function RecordingOverlay({
|
|||||||
|
|
||||||
{/* Bottom gradient scrim for keyboard hint readability */}
|
{/* Bottom gradient scrim for keyboard hint readability */}
|
||||||
{(isRecording || isReviewing) && !isLoading && (
|
{(isRecording || isReviewing) && !isLoading && (
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t from-black/60 to-transparent" />
|
<div className="from-scrim/60 pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t to-transparent" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top center: recording indicator */}
|
{/* Top center: recording indicator */}
|
||||||
<div className="absolute top-8 z-10">
|
<div className="absolute top-8 z-10">
|
||||||
{isRecording && !isLoading ? (
|
{isRecording && !isLoading ? (
|
||||||
<RecordingTimer />
|
<RecordingTimer elapsed={elapsed} isWarning={isWarning} />
|
||||||
) : isReviewing ? (
|
) : isReviewing ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-white/80">Review recording</span>
|
<span className="text-sm text-white/80">Review recording</span>
|
||||||
|
|||||||
@@ -63,37 +63,38 @@ export function TaskComposeStep({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16"
|
className="bg-background/95 absolute inset-0 z-50 flex flex-col pt-16 backdrop-blur-sm"
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
|
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1 text-xs text-white/50">Task</Label>
|
<Label className="text-muted-foreground mb-1 text-xs">Task</Label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
autoFocus
|
autoFocus
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="What needs to get done?"
|
placeholder="What needs to get done?"
|
||||||
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1 text-xs text-white/50">Notes</Label>
|
<Label className="text-muted-foreground mb-1 text-xs">Notes</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={notes}
|
value={notes}
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
placeholder="Optional details…"
|
placeholder="Optional details…"
|
||||||
className="min-h-20 border-white/10 bg-white/5 text-white placeholder:text-white/30 focus-visible:border-white/30 focus-visible:ring-0 dark:bg-white/5"
|
className="min-h-20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1 text-xs text-white/50">Assign to</Label>
|
<Label className="text-muted-foreground mb-1 text-xs">
|
||||||
|
Assign to
|
||||||
|
</Label>
|
||||||
<Select value={assignedTo} onValueChange={setAssignedTo}>
|
<Select value={assignedTo} onValueChange={setAssignedTo}>
|
||||||
<SelectTrigger className="w-full border-white/10 bg-white/5 text-white">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Unassigned" />
|
<SelectValue placeholder="Unassigned" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -108,7 +109,7 @@ export function TaskComposeStep({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
|
<div className="text-muted-foreground absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm">
|
||||||
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
|
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
|
||||||
cancel
|
cancel
|
||||||
</KeyHint>
|
</KeyHint>
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export function TextEditor({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const keyboardHints = (
|
const keyboardHints = (
|
||||||
<div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50">
|
<div className="text-muted-foreground absolute bottom-4 flex items-center gap-4 text-sm">
|
||||||
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
|
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
|
||||||
cancel
|
cancel
|
||||||
</KeyHint>
|
</KeyHint>
|
||||||
@@ -161,8 +161,8 @@ export function TextEditor({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 z-50 flex items-center justify-center bg-black/90',
|
'bg-background/95 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm',
|
||||||
isDragging && 'ring-2 ring-inset ring-white/30',
|
isDragging && 'ring-primary/40 ring-2 ring-inset',
|
||||||
)}
|
)}
|
||||||
{...dropZoneProps}
|
{...dropZoneProps}
|
||||||
>
|
>
|
||||||
@@ -174,7 +174,7 @@ export function TextEditor({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Type a message..."
|
placeholder="Type a message..."
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none',
|
'text-foreground placeholder-muted-foreground w-full resize-none border-none bg-transparent p-8 text-center outline-none',
|
||||||
style.size,
|
style.size,
|
||||||
style.weight,
|
style.weight,
|
||||||
)}
|
)}
|
||||||
@@ -189,13 +189,13 @@ export function TextEditor({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0 z-50 flex items-center justify-center bg-black/90',
|
'bg-background/95 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm',
|
||||||
isDragging && 'ring-2 ring-inset ring-white/30',
|
isDragging && 'ring-primary/40 ring-2 ring-inset',
|
||||||
)}
|
)}
|
||||||
{...dropZoneProps}
|
{...dropZoneProps}
|
||||||
onKeyDownCapture={handleKeyDown}
|
onKeyDownCapture={handleKeyDown}
|
||||||
>
|
>
|
||||||
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 max-w-[calc(var(--message-content-width)_+_var(--message-editor-gutter)*2)] flex-col overflow-hidden rounded border border-white/10 bg-white/5 backdrop-blur-xl animate-in fade-in zoom-in-95 duration-200">
|
<div className="bg-card text-card-foreground border-border animate-in fade-in zoom-in-95 mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 max-w-[calc(var(--message-content-width)_+_var(--message-editor-gutter)*2)] flex-col overflow-hidden rounded border duration-200">
|
||||||
{/* No padding here: the editor's own scroll box hosts the slash menu,
|
{/* No padding here: the editor's own scroll box hosts the slash menu,
|
||||||
so we pad inside the editor (ProseMirror) instead. That keeps the
|
so we pad inside the editor (ProseMirror) instead. That keeps the
|
||||||
menu's clipping bounds the full card rather than the inset box. */}
|
menu's clipping bounds the full card rather than the inset box. */}
|
||||||
@@ -208,7 +208,7 @@ export function TextEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasEnrichments && strip && (
|
{hasEnrichments && strip && (
|
||||||
<div className="shrink-0 border-t border-white/10 px-5 py-3">
|
<div className="border-border shrink-0 border-t px-5 py-3">
|
||||||
{strip}
|
{strip}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
import { OnboardingKeyboard } from './onboarding-keyboard';
|
||||||
|
import { OnboardingStep, SUCCESS_DWELL_MS } from './onboarding-step';
|
||||||
|
import { useHoldGesture } from './use-hold-gesture';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visible hold duration for the lesson. Deliberately longer than the real
|
||||||
|
* HOLD_THRESHOLD_MS (which is imperceptible) so the gesture is teachable.
|
||||||
|
*/
|
||||||
|
const ONBOARDING_HOLD_MS = 600;
|
||||||
|
|
||||||
|
export function HoldStep({ onAdvance }: { onAdvance: () => void }) {
|
||||||
|
const { state, progress } = useHoldGesture({
|
||||||
|
targetKey: '`',
|
||||||
|
targetCode: 'Backquote',
|
||||||
|
holdMs: ONBOARDING_HOLD_MS,
|
||||||
|
});
|
||||||
|
const done = state === 'success';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!done) return;
|
||||||
|
const t = setTimeout(onAdvance, SUCCESS_DWELL_MS);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [done, onAdvance]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OnboardingStep
|
||||||
|
title={done ? 'That is push-to-talk' : 'Hold to record'}
|
||||||
|
subtitle={
|
||||||
|
done
|
||||||
|
? 'Hold ` whenever you want to speak, release when you are done.'
|
||||||
|
: 'Press and hold the ` key, like a walkie-talkie.'
|
||||||
|
}
|
||||||
|
status={
|
||||||
|
done ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-300">
|
||||||
|
<Check className="size-4" /> Nice.
|
||||||
|
</span>
|
||||||
|
) : state === 'holding' ? (
|
||||||
|
'Keep holding…'
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<OnboardingKeyboard
|
||||||
|
highlightKey="`"
|
||||||
|
state={done ? 'success' : state === 'holding' ? 'active' : 'idle'}
|
||||||
|
progress={progress}
|
||||||
|
/>
|
||||||
|
</OnboardingStep>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type KeyState = 'idle' | 'active' | 'success';
|
||||||
|
|
||||||
|
interface OnboardingKeyboardProps {
|
||||||
|
/** The key to emphasise. Defaults to the record key. */
|
||||||
|
highlightKey?: string;
|
||||||
|
state: KeyState;
|
||||||
|
/** Hold progress (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);
|
const requestIntent = useComposeIntentStore((s) => s.request);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 text-sm text-white/50">
|
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||||
<KeyHint keys={['↑↓', 'Enter']}>navigate</KeyHint>
|
<KeyHint keys={['↑↓', 'Enter']}>navigate</KeyHint>
|
||||||
<KeyHint keys="1–9">jump</KeyHint>
|
<KeyHint keys="1–9">jump</KeyHint>
|
||||||
<VideoAudioToggle />
|
<VideoAudioToggle />
|
||||||
|
|||||||
@@ -38,14 +38,16 @@ export function DeletedParticleView({
|
|||||||
}, [paused, onEnded, particle.id]);
|
}, [paused, onEnded, particle.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8">
|
<div className="flex h-full w-full items-center justify-center px-8">
|
||||||
<div className="flex flex-col items-center gap-3 text-center">
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
<Trash2 className="text-white/40 size-6" />
|
<Trash2 className="text-muted-foreground size-6" />
|
||||||
<p className="text-white/70 text-base font-medium">
|
<p className="text-foreground text-base font-medium">
|
||||||
This particle was deleted
|
This particle was deleted
|
||||||
</p>
|
</p>
|
||||||
{deleter && (
|
{deleter && (
|
||||||
<p className="text-white/40 text-xs">by {deleter.displayName}</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
|
by {deleter.displayName}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export const MediaParticleView = forwardRef<
|
|||||||
|
|
||||||
if (isAudio) {
|
if (isAudio) {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
|
<div className="bg-scrim/90 relative flex h-full w-full items-center justify-center">
|
||||||
<audio
|
<audio
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
audioRef.current = el;
|
audioRef.current = el;
|
||||||
@@ -186,7 +186,7 @@ export const MediaParticleView = forwardRef<
|
|||||||
playsInline
|
playsInline
|
||||||
onEnded={onEnded}
|
onEnded={onEnded}
|
||||||
onTimeUpdate={handleTimeUpdate}
|
onTimeUpdate={handleTimeUpdate}
|
||||||
className="h-full w-full object-contain bg-black"
|
className="bg-black h-full w-full object-contain"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{transcript && (
|
{transcript && (
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ function ImageAttachment({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading || !url) {
|
if (isLoading || !url) {
|
||||||
return <Skeleton className="h-20 w-20 shrink-0 rounded-lg bg-white/10" />;
|
return <Skeleton className="h-20 w-20 shrink-0 rounded-lg" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownload = (e: React.MouseEvent) => {
|
const handleDownload = (e: React.MouseEvent) => {
|
||||||
@@ -78,7 +78,7 @@ function ImageAttachment({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onPreview();
|
onPreview();
|
||||||
}}
|
}}
|
||||||
className="group relative block h-30 w-40 shrink-0 overflow-hidden rounded-lg bg-white/10"
|
className="bg-muted group relative block h-30 w-40 shrink-0 overflow-hidden rounded-lg"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
@@ -89,7 +89,7 @@ function ImageAttachment({
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
className="absolute bottom-1 right-1 rounded-full bg-black/60 p-1 text-white/70 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
|
className="bg-scrim/60 absolute bottom-1 right-1 rounded-full p-1 text-white/80 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<Download className="size-3.5" />
|
<Download className="size-3.5" />
|
||||||
</span>
|
</span>
|
||||||
@@ -123,14 +123,14 @@ function FileAttachment({
|
|||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
className="flex shrink-0 cursor-pointer flex-col gap-1.5 rounded-lg bg-white/10 px-3 py-2 transition-colors hover:bg-white/15"
|
className="bg-muted hover:bg-accent flex shrink-0 cursor-pointer flex-col gap-1.5 rounded-lg px-3 py-2 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileIcon className="size-4 shrink-0 text-white/60" />
|
<FileIcon className="text-muted-foreground size-4 shrink-0" />
|
||||||
<span className="max-w-[10rem] truncate text-xs font-medium text-white/90">
|
<span className="text-foreground max-w-[10rem] truncate text-xs font-medium">
|
||||||
{particle.properties.filename}
|
{particle.properties.filename}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-white/40">
|
<span className="text-muted-foreground text-[10px]">
|
||||||
{formatFileSize(particle.properties.size_bytes)}
|
{formatFileSize(particle.properties.size_bytes)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +138,7 @@ function FileAttachment({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="xs"
|
size="xs"
|
||||||
className="text-white/70 hover:bg-white/10 hover:text-white"
|
className="text-muted-foreground"
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
>
|
>
|
||||||
<ExternalLink data-icon="inline-start" />
|
<ExternalLink data-icon="inline-start" />
|
||||||
@@ -147,7 +147,7 @@ function FileAttachment({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="xs"
|
size="xs"
|
||||||
className="text-white/70 hover:bg-white/10 hover:text-white"
|
className="text-muted-foreground"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
>
|
>
|
||||||
<Download data-icon="inline-start" />
|
<Download data-icon="inline-start" />
|
||||||
@@ -177,7 +177,7 @@ function CompactAttachmentItem({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openParticle(particle, index, url, onPreview);
|
openParticle(particle, index, url, onPreview);
|
||||||
}}
|
}}
|
||||||
className="flex w-full items-center gap-2 rounded-md bg-white/10 px-2.5 py-1.5 text-left transition-colors hover:bg-white/15"
|
className="bg-muted hover:bg-accent flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left transition-colors"
|
||||||
>
|
>
|
||||||
{isImage ? (
|
{isImage ? (
|
||||||
url ? (
|
url ? (
|
||||||
@@ -187,15 +187,15 @@ function CompactAttachmentItem({
|
|||||||
className="size-5 shrink-0 rounded object-cover"
|
className="size-5 shrink-0 rounded object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ImageIcon className="size-4 shrink-0 text-white/50" />
|
<ImageIcon className="text-muted-foreground size-4 shrink-0" />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<FileIcon className="size-4 shrink-0 text-white/50" />
|
<FileIcon className="text-muted-foreground size-4 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="min-w-0 truncate text-xs text-white/80">
|
<span className="text-foreground min-w-0 truncate text-xs">
|
||||||
{particle.properties.filename}
|
{particle.properties.filename}
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 text-[10px] text-white/40">
|
<span className="text-muted-foreground shrink-0 text-[10px]">
|
||||||
{formatFileSize(particle.properties.size_bytes)}
|
{formatFileSize(particle.properties.size_bytes)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ const StreamRow = memo(function StreamRow({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{shortcutKey && (
|
{shortcutKey && (
|
||||||
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground">
|
<kbd className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded font-mono text-xs">
|
||||||
{shortcutKey}
|
{shortcutKey}
|
||||||
</kbd>
|
</kbd>
|
||||||
)}
|
)}
|
||||||
@@ -220,8 +220,8 @@ const StreamRow = memo(function StreamRow({
|
|||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
{hasActiveHuddle && (
|
{hasActiveHuddle && (
|
||||||
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
|
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
|
||||||
<Headphones className="size-3 text-red-400" />
|
<Headphones className="size-3 text-red-600 dark:text-red-400" />
|
||||||
<span className="text-[10px] font-medium text-red-400">
|
<span className="text-[10px] font-medium text-red-600 dark:text-red-400">
|
||||||
{huddleCount}
|
{huddleCount}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -298,7 +298,7 @@ const FolderRow = memo(function FolderRow({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{shortcutKey && (
|
{shortcutKey && (
|
||||||
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground">
|
<kbd className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded font-mono text-xs">
|
||||||
{shortcutKey}
|
{shortcutKey}
|
||||||
</kbd>
|
</kbd>
|
||||||
)}
|
)}
|
||||||
@@ -356,7 +356,7 @@ const LeafRow = memo(function LeafRow({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{shortcutKey && (
|
{shortcutKey && (
|
||||||
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground">
|
<kbd className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded font-mono text-xs">
|
||||||
{shortcutKey}
|
{shortcutKey}
|
||||||
</kbd>
|
</kbd>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ function MediaPreview({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-black/90">
|
<div className="bg-muted text-muted-foreground flex h-full w-full flex-col items-center justify-center gap-2">
|
||||||
<Mic className="h-8 w-8 text-white/60" />
|
<Mic className="h-8 w-8" />
|
||||||
<span className="font-mono text-xs text-white/50">{durationLabel}</span>
|
<span className="font-mono text-xs">{durationLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -103,8 +103,8 @@ function VideoThumbnail({
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center bg-black/80">
|
<div className="bg-muted flex h-full w-full items-center justify-center">
|
||||||
<Video className="h-8 w-8 text-white/40" />
|
<Video className="text-muted-foreground h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ function VideoThumbnail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full bg-black">
|
<div className="bg-muted relative h-full w-full">
|
||||||
<video
|
<video
|
||||||
src={`${url}#t=2`}
|
src={`${url}#t=2`}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
@@ -122,7 +122,7 @@ function VideoThumbnail({
|
|||||||
playsInline
|
playsInline
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
<span className="absolute right-1.5 bottom-1.5 rounded bg-black/70 px-1.5 py-0.5 font-mono text-[10px] text-white/80">
|
<span className="bg-scrim/70 absolute bottom-1.5 right-1.5 rounded px-1.5 py-0.5 font-mono text-[10px] text-white">
|
||||||
{duration}
|
{duration}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ function LeafParticleView({
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-0 flex-1 bg-black text-white [--stream-safe-top:2rem] [--stream-safe-bottom:2rem]">
|
<div className="bg-background min-h-0 flex-1 [--stream-safe-top:2rem] [--stream-safe-bottom:2rem]">
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -77,10 +77,10 @@ export function PlaybackPageIndicator({
|
|||||||
className="group relative block h-3 w-full"
|
className="group relative block h-3 w-full"
|
||||||
>
|
>
|
||||||
{/* Dim track */}
|
{/* Dim track */}
|
||||||
<div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/30 transition-all group-hover:h-1.5" />
|
<div className="bg-muted-foreground/30 absolute inset-x-0 bottom-0 h-[3px] transition-all group-hover:h-1.5" />
|
||||||
{/* Fill */}
|
{/* Fill */}
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 bottom-0 h-[3px] bg-white/90 transition-all group-hover:h-1.5"
|
className="bg-foreground absolute bottom-0 left-0 h-[3px] transition-all group-hover:h-1.5"
|
||||||
style={{
|
style={{
|
||||||
width:
|
width:
|
||||||
i < current
|
i < current
|
||||||
@@ -107,7 +107,7 @@ export function PlaybackPageIndicator({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{paginated && showTracks && current >= 0 && (
|
{paginated && showTracks && current >= 0 && (
|
||||||
<div className="pointer-events-none pt-1 text-center text-[10px] font-medium tabular-nums tracking-wide text-white/40">
|
<div className="text-muted-foreground pointer-events-none pt-1 text-center text-[10px] font-medium tabular-nums tracking-wide">
|
||||||
{current + 1} / {total}
|
{current + 1} / {total}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -141,7 +141,7 @@ function GhostStub({
|
|||||||
className="group relative block h-3 w-2 shrink-0"
|
className="group relative block h-3 w-2 shrink-0"
|
||||||
aria-label="Jump to adjacent page"
|
aria-label="Jump to adjacent page"
|
||||||
>
|
>
|
||||||
<div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/15 transition-all group-hover:h-1.5 group-hover:bg-white/40" />
|
<div className="bg-muted-foreground/20 group-hover:bg-muted-foreground/40 absolute inset-x-0 bottom-0 h-[3px] transition-all group-hover:h-1.5" />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -166,7 +166,7 @@ function SegmentPresenceAvatars({
|
|||||||
className={
|
className={
|
||||||
onlineHumanIds?.has(human.humanId)
|
onlineHumanIds?.has(human.humanId)
|
||||||
? 'ring-2 ring-green-500'
|
? 'ring-2 ring-green-500'
|
||||||
: 'ring-1 ring-black/50'
|
: 'ring-1 ring-background'
|
||||||
}
|
}
|
||||||
avatarObjectId={human.avatarObjectId}
|
avatarObjectId={human.avatarObjectId}
|
||||||
initials={human.emailPrefix.slice(0, 2).toUpperCase()}
|
initials={human.emailPrefix.slice(0, 2).toUpperCase()}
|
||||||
@@ -178,7 +178,9 @@ function SegmentPresenceAvatars({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
{overflow > 0 && (
|
{overflow > 0 && (
|
||||||
<span className="text-[10px] text-white/70 pl-1">+{overflow}</span>
|
<span className="text-muted-foreground pl-1 text-[10px]">
|
||||||
|
+{overflow}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -77,12 +77,12 @@ export function ReactionBar({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1 rounded-full px-2 py-0.5 text-xs backdrop-blur-sm transition-colors',
|
'flex items-center gap-1 rounded-full px-2 py-0.5 text-xs backdrop-blur-sm transition-colors',
|
||||||
isMine
|
isMine
|
||||||
? 'bg-white/20 ring-1 ring-white/40'
|
? 'bg-primary/15 text-foreground ring-1 ring-primary/40'
|
||||||
: 'bg-black/40 hover:bg-black/50',
|
: 'bg-card/70 text-foreground border border-border hover:bg-accent',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-sm">{emoji}</span>
|
<span className="text-sm">{emoji}</span>
|
||||||
<span className="text-white/80">{reactors.length}</span>
|
<span className="text-muted-foreground">{reactors.length}</span>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="left" className="text-xs">
|
<TooltipContent side="left" className="text-xs">
|
||||||
@@ -109,8 +109,8 @@ export function ReactionBar({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex max-w-[200px] items-center gap-1.5 rounded-full py-0.5 pl-0.5 pr-2.5 text-xs backdrop-blur-sm transition-colors',
|
'flex max-w-[200px] items-center gap-1.5 rounded-full py-0.5 pl-0.5 pr-2.5 text-xs backdrop-blur-sm transition-colors',
|
||||||
isMine
|
isMine
|
||||||
? 'bg-white/20 ring-1 ring-white/40'
|
? 'bg-primary/15 text-foreground ring-1 ring-primary/40'
|
||||||
: 'bg-black/40 hover:bg-black/50',
|
: 'bg-card/70 text-foreground border border-border hover:bg-accent',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<HumanAvatar
|
<HumanAvatar
|
||||||
@@ -118,11 +118,11 @@ export function ReactionBar({
|
|||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
avatarObjectId={firstReactor.avatarObjectId}
|
avatarObjectId={firstReactor.avatarObjectId}
|
||||||
initials={firstReactor.initials}
|
initials={firstReactor.initials}
|
||||||
fallbackClassName="bg-white/15 text-[9px] font-medium text-white"
|
fallbackClassName="bg-muted text-muted-foreground text-[9px] font-medium"
|
||||||
/>
|
/>
|
||||||
<span className="truncate text-white/90">{text}</span>
|
<span className="truncate text-foreground">{text}</span>
|
||||||
{reactors.length > 1 && (
|
{reactors.length > 1 && (
|
||||||
<span className="shrink-0 text-white/60">
|
<span className="text-muted-foreground shrink-0">
|
||||||
{reactors.length}
|
{reactors.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -154,7 +154,7 @@ export function ReactionBar({
|
|||||||
|
|
||||||
{/* Picker / actions */}
|
{/* Picker / actions */}
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<div className="flex flex-col items-center gap-0.5 rounded-full bg-black/40 px-0.5 py-1.5 backdrop-blur-sm">
|
<div className="bg-card/80 border-border flex flex-col items-center gap-0.5 rounded-full border px-0.5 py-1.5 backdrop-blur-sm">
|
||||||
{REACTION_EMOJIS.map((emoji) => {
|
{REACTION_EMOJIS.map((emoji) => {
|
||||||
if (activeEmojis.includes(emoji)) return null;
|
if (activeEmojis.includes(emoji)) return null;
|
||||||
return (
|
return (
|
||||||
@@ -164,7 +164,7 @@ export function ReactionBar({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleToggle(emoji);
|
handleToggle(emoji);
|
||||||
}}
|
}}
|
||||||
className="rounded-full px-0.5 py-1 text-sm transition-colors hover:bg-white/15"
|
className="hover:bg-accent rounded-full px-0.5 py-1 text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{emoji}
|
{emoji}
|
||||||
</button>
|
</button>
|
||||||
@@ -175,9 +175,9 @@ export function ReactionBar({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
}}
|
}}
|
||||||
className="flex size-5 items-center justify-center rounded-full transition-colors hover:bg-white/15"
|
className="hover:bg-accent flex size-5 items-center justify-center rounded-full transition-colors"
|
||||||
>
|
>
|
||||||
<X className="size-3 text-white/60" />
|
<X className="text-muted-foreground size-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -189,14 +189,14 @@ export function ReactionBar({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onOpenTextReaction();
|
onOpenTextReaction();
|
||||||
}}
|
}}
|
||||||
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
|
className="bg-card/70 border-border hover:bg-accent flex size-6 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
|
||||||
>
|
>
|
||||||
<Type className="size-3 text-white/60" />
|
<Type className="text-muted-foreground size-3" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="left" className="text-xs">
|
<TooltipContent side="left" className="text-xs">
|
||||||
Quick reply{' '}
|
Quick reply{' '}
|
||||||
<kbd className="ml-1 rounded bg-white/10 px-1 font-mono text-[10px]">
|
<kbd className="bg-muted ml-1 rounded px-1 font-mono text-[10px]">
|
||||||
R
|
R
|
||||||
</kbd>
|
</kbd>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@@ -206,9 +206,9 @@ export function ReactionBar({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setExpanded(true);
|
setExpanded(true);
|
||||||
}}
|
}}
|
||||||
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
|
className="bg-card/70 border-border hover:bg-accent flex size-6 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="size-3 text-white/60" />
|
<Plus className="text-muted-foreground size-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -56,17 +56,17 @@ export function RenameStreamOverlay({
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[100]">
|
<div className="fixed inset-0 z-[100]">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
className="absolute inset-0 bg-scrim/60 backdrop-blur-sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
<div className="bg-popover/95 text-popover-foreground border-border absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border p-6 shadow-2xl backdrop-blur-xl">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold text-white/70">Rename stream</h2>
|
<h2 className="text-sm font-semibold">Rename stream</h2>
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys="Esc"
|
keys="Esc"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close (or press Esc)"
|
title="Close (or press Esc)"
|
||||||
className="text-xs text-white/30"
|
className="text-muted-foreground text-xs"
|
||||||
>
|
>
|
||||||
to close
|
to close
|
||||||
</KeyHint>
|
</KeyHint>
|
||||||
@@ -85,7 +85,6 @@ export function RenameStreamOverlay({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Stream name"
|
placeholder="Stream name"
|
||||||
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-end gap-2">
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function BottomBar({
|
|||||||
<div className="flex items-center justify-center px-3 pt-2 gap-2">
|
<div className="flex items-center justify-center px-3 pt-2 gap-2">
|
||||||
{exitRemainingMs !== null && (
|
{exitRemainingMs !== null && (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<span className="rounded-full bg-black/30 px-2 text-xs text-white/70 backdrop-blur-sm">
|
<span className="bg-card/70 text-muted-foreground border-border rounded-full border px-2 text-xs backdrop-blur-sm">
|
||||||
Closing in {Math.ceil(exitRemainingMs / 1000)}s
|
Closing in {Math.ceil(exitRemainingMs / 1000)}s
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,7 +91,7 @@ export function StreamViewControls({
|
|||||||
}) {
|
}) {
|
||||||
const requestIntent = useComposeIntentStore((s) => s.request);
|
const requestIntent = useComposeIntentStore((s) => s.request);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 text-sm text-white/50">
|
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||||
{showEscape && (
|
{showEscape && (
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys="Esc"
|
keys="Esc"
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { KeyHint } from '@/components/key-hint';
|
|||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
|
||||||
interface StreamListSidebarProps {
|
interface StreamListSidebarProps {
|
||||||
streamName: string;
|
|
||||||
items: Particle[];
|
items: Particle[];
|
||||||
networkId: string;
|
networkId: string;
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
@@ -24,7 +23,6 @@ interface StreamListSidebarProps {
|
|||||||
* nothing auto-advances.
|
* nothing auto-advances.
|
||||||
*/
|
*/
|
||||||
export function StreamListSidebar({
|
export function StreamListSidebar({
|
||||||
streamName,
|
|
||||||
items,
|
items,
|
||||||
networkId,
|
networkId,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
@@ -41,18 +39,17 @@ export function StreamListSidebar({
|
|||||||
}, [currentIndex]);
|
}, [currentIndex]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="dark flex w-96 shrink-0 flex-col border-l border-white/10 bg-zinc-950">
|
<aside className="bg-sidebar text-sidebar-foreground border-border flex w-60 shrink-0 flex-col overflow-hidden border-l">
|
||||||
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-4 py-3">
|
<div className="border-border flex shrink-0 items-center gap-2 border-b px-4 py-3">
|
||||||
<List className="size-3.5 text-white/40" />
|
<List className="text-muted-foreground size-3.5" />
|
||||||
<span className="truncate text-sm font-medium text-white/90">
|
<span className="mr-auto truncate text-sm font-medium">
|
||||||
{streamName}
|
{items.length} messages
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-auto text-xs text-white/40">{items.length}</span>
|
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys="L"
|
keys="L"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
title="Hide list (or press L)"
|
title="Hide list (or press L)"
|
||||||
className="text-xs text-white/40"
|
className="text-muted-foreground text-xs"
|
||||||
>
|
>
|
||||||
to close
|
to close
|
||||||
</KeyHint>
|
</KeyHint>
|
||||||
@@ -75,7 +72,7 @@ export function StreamListSidebar({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<p className="px-2 py-8 text-center text-sm text-white/40">
|
<p className="text-muted-foreground px-2 py-8 text-center text-sm">
|
||||||
No particles in this stream yet.
|
No particles in this stream yet.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -108,8 +105,8 @@ function ChatRow({
|
|||||||
if (e.key === 'Enter') onClick();
|
if (e.key === 'Enter') onClick();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors',
|
'flex cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors',
|
||||||
isSelected ? 'bg-white/10' : 'hover:bg-white/5',
|
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<HumanAvatar
|
<HumanAvatar
|
||||||
@@ -117,14 +114,14 @@ function ChatRow({
|
|||||||
className="mt-0.5 shrink-0"
|
className="mt-0.5 shrink-0"
|
||||||
initials={sender.initials}
|
initials={sender.initials}
|
||||||
avatarObjectId={sender.avatarObjectId}
|
avatarObjectId={sender.avatarObjectId}
|
||||||
fallbackClassName="bg-white/10 text-white/80 text-[10px] font-medium"
|
fallbackClassName="bg-muted text-muted-foreground text-[10px] font-medium"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<span className="truncate text-xs font-semibold text-white/90">
|
<span className="truncate text-xs font-semibold">
|
||||||
{sender.displayName}
|
{sender.displayName}
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 text-[10px] text-white/35">
|
<span className="text-muted-foreground shrink-0 text-[10px]">
|
||||||
<RelativeTimestamp date={particle.created_at} />
|
<RelativeTimestamp date={particle.created_at} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,14 +134,16 @@ function ChatRow({
|
|||||||
function ChatRowContent({ particle }: { particle: Particle }) {
|
function ChatRowContent({ particle }: { particle: Particle }) {
|
||||||
if (isParticleDeleted(particle)) {
|
if (isParticleDeleted(particle)) {
|
||||||
return (
|
return (
|
||||||
<p className="text-xs italic text-white/35">This particle was deleted</p>
|
<p className="text-muted-foreground text-xs italic">
|
||||||
|
This particle was deleted
|
||||||
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
return (
|
return (
|
||||||
<p className="line-clamp-3 text-xs leading-relaxed whitespace-pre-line text-white/70">
|
<p className="text-muted-foreground line-clamp-2 whitespace-pre-line text-xs leading-relaxed [overflow-wrap:anywhere]">
|
||||||
{particle.properties.content}
|
{particle.properties.content}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
@@ -169,13 +168,13 @@ function ChatRowContent({ particle }: { particle: Particle }) {
|
|||||||
: '';
|
: '';
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="flex items-center gap-1.5 text-xs text-white/70">
|
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||||
<Icon className="size-3.5 shrink-0 text-white/50" />
|
<Icon className="text-muted-foreground size-3.5 shrink-0" />
|
||||||
{label}
|
{label}
|
||||||
{duration}
|
{duration}
|
||||||
</span>
|
</span>
|
||||||
{transcript && (
|
{transcript && (
|
||||||
<p className="line-clamp-2 text-xs leading-relaxed text-white/45">
|
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||||
{transcript}
|
{transcript}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -184,8 +183,8 @@ function ChatRowContent({ particle }: { particle: Particle }) {
|
|||||||
}
|
}
|
||||||
case 'file':
|
case 'file':
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1.5 text-xs text-white/70">
|
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||||
<FileText className="size-3.5 shrink-0 text-white/50" />
|
<FileText className="text-muted-foreground size-3.5 shrink-0" />
|
||||||
<span className="truncate">{particle.properties.filename}</span>
|
<span className="truncate">{particle.properties.filename}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -194,21 +193,26 @@ function ChatRowContent({ particle }: { particle: Particle }) {
|
|||||||
const doneCount = checklist.filter((item) => item.done).length;
|
const doneCount = checklist.filter((item) => item.done).length;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="flex items-center gap-1.5 text-xs text-white/70">
|
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||||
<CircleCheck
|
<CircleCheck
|
||||||
className={cn(
|
className={cn(
|
||||||
'size-3.5 shrink-0',
|
'size-3.5 shrink-0',
|
||||||
done ? 'text-emerald-400' : 'text-white/50',
|
done
|
||||||
|
? 'text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn('truncate', done && 'text-white/40 line-through')}
|
className={cn(
|
||||||
|
'truncate',
|
||||||
|
done && 'text-muted-foreground line-through',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{checklist.length > 0 && (
|
{checklist.length > 0 && (
|
||||||
<span className="pl-5 text-[10px] text-white/40">
|
<span className="text-muted-foreground pl-5 text-[10px]">
|
||||||
{doneCount} / {checklist.length} subtasks
|
{doneCount} / {checklist.length} subtasks
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -217,11 +221,11 @@ function ChatRowContent({ particle }: { particle: Particle }) {
|
|||||||
}
|
}
|
||||||
case 'paper':
|
case 'paper':
|
||||||
return (
|
return (
|
||||||
<p className="truncate text-xs text-white/70">
|
<p className="text-muted-foreground truncate text-xs">
|
||||||
{particle.properties.title}
|
{particle.properties.title}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return <p className="text-xs text-white/45">{particle.type}</p>;
|
return <p className="text-muted-foreground text-xs">{particle.type}</p>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,18 +97,18 @@ export function StreamMembersOverlay({
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[100]">
|
<div className="fixed inset-0 z-[100]">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
className="bg-scrim/60 absolute inset-0 backdrop-blur-sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-1/2 top-1/2 flex max-h-[80vh] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
|
<div className="bg-popover/95 text-popover-foreground border-border absolute left-1/2 top-1/2 flex max-h-[80vh] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border p-6 shadow-2xl backdrop-blur-xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold text-white/70">Members</h2>
|
<h2 className="text-sm font-semibold">Members</h2>
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys="Esc"
|
keys="Esc"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close (or press Esc)"
|
title="Close (or press Esc)"
|
||||||
className="text-xs text-white/30"
|
className="text-muted-foreground text-xs"
|
||||||
>
|
>
|
||||||
to close
|
to close
|
||||||
</KeyHint>
|
</KeyHint>
|
||||||
@@ -116,11 +116,11 @@ export function StreamMembersOverlay({
|
|||||||
|
|
||||||
{/* Visibility */}
|
{/* Visibility */}
|
||||||
<section className="mb-4">
|
<section className="mb-4">
|
||||||
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
<h3 className="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
|
||||||
Visibility
|
Visibility
|
||||||
</h3>
|
</h3>
|
||||||
{isCreator ? (
|
{isCreator ? (
|
||||||
<div className="grid grid-cols-2 gap-1 rounded-lg bg-white/5 p-1">
|
<div className="bg-muted grid grid-cols-2 gap-1 rounded-lg p-1">
|
||||||
<VisibilityPill
|
<VisibilityPill
|
||||||
active={visibility.mode === 'network'}
|
active={visibility.mode === 'network'}
|
||||||
icon={<Globe className="size-3.5" />}
|
icon={<Globe className="size-3.5" />}
|
||||||
@@ -135,15 +135,15 @@ export function StreamMembersOverlay({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-sm text-white/70">
|
<div className="text-foreground flex items-center gap-2 text-sm">
|
||||||
{visibility.mode === 'network' ? (
|
{visibility.mode === 'network' ? (
|
||||||
<>
|
<>
|
||||||
<Globe className="size-3.5 text-white/40" />
|
<Globe className="text-muted-foreground size-3.5" />
|
||||||
<span>Everyone in {network?.name ?? 'network'}</span>
|
<span>Everyone in {network?.name ?? 'network'}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Lock className="size-3.5 text-white/40" />
|
<Lock className="text-muted-foreground size-3.5" />
|
||||||
<span>{memberIds.length} specific people</span>
|
<span>{memberIds.length} specific people</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -153,9 +153,11 @@ export function StreamMembersOverlay({
|
|||||||
|
|
||||||
{/* Member list */}
|
{/* Member list */}
|
||||||
<section className="flex min-h-0 flex-1 flex-col">
|
<section className="flex min-h-0 flex-1 flex-col">
|
||||||
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
<h3 className="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
|
||||||
{visibility.mode === 'network' ? 'Has access' : 'People'}{' '}
|
{visibility.mode === 'network' ? 'Has access' : 'People'}{' '}
|
||||||
<span className="ml-1 text-white/20">{memberIds.length}</span>
|
<span className="text-muted-foreground ml-1">
|
||||||
|
{memberIds.length}
|
||||||
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
<ul className="flex flex-col gap-0.5 pr-2">
|
<ul className="flex flex-col gap-0.5 pr-2">
|
||||||
@@ -167,7 +169,7 @@ export function StreamMembersOverlay({
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={id}
|
key={id}
|
||||||
className="group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm text-white/70"
|
className="text-foreground group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm"
|
||||||
>
|
>
|
||||||
<HumanAvatar
|
<HumanAvatar
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -178,13 +180,13 @@ export function StreamMembersOverlay({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 truncate',
|
'flex-1 truncate',
|
||||||
!display.exists && 'italic text-white/40',
|
!display.exists && 'text-muted-foreground italic',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{display.displayName}
|
{display.displayName}
|
||||||
</span>
|
</span>
|
||||||
{isCreatorRow && (
|
{isCreatorRow && (
|
||||||
<span className="text-[10px] uppercase tracking-wider text-white/30">
|
<span className="text-muted-foreground text-[10px] uppercase tracking-wider">
|
||||||
Creator
|
Creator
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -192,7 +194,7 @@ export function StreamMembersOverlay({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeMember(id)}
|
onClick={() => removeMember(id)}
|
||||||
className="rounded p-1 text-white/30 opacity-0 transition-opacity hover:bg-white/10 hover:text-white/70 group-hover:opacity-100"
|
className="text-muted-foreground hover:bg-accent hover:text-foreground rounded p-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
aria-label={`Remove ${display.displayName}`}
|
aria-label={`Remove ${display.displayName}`}
|
||||||
>
|
>
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
@@ -209,8 +211,8 @@ export function StreamMembersOverlay({
|
|||||||
{isCreator &&
|
{isCreator &&
|
||||||
visibility.mode === 'custom' &&
|
visibility.mode === 'custom' &&
|
||||||
availableToAdd.length > 0 && (
|
availableToAdd.length > 0 && (
|
||||||
<section className="mt-4 border-t border-white/5 pt-4">
|
<section className="border-border mt-4 border-t pt-4">
|
||||||
<h3 className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
<h3 className="text-muted-foreground mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest">
|
||||||
<UserPlus className="size-3" />
|
<UserPlus className="size-3" />
|
||||||
Add people
|
Add people
|
||||||
</h3>
|
</h3>
|
||||||
@@ -222,7 +224,7 @@ export function StreamMembersOverlay({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => addMember(human.id)}
|
onClick={() => addMember(human.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm text-white/70 transition-colors hover:bg-white/5',
|
'text-foreground hover:bg-accent flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm transition-colors',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<HumanAvatar
|
<HumanAvatar
|
||||||
@@ -234,7 +236,7 @@ export function StreamMembersOverlay({
|
|||||||
<span className="flex-1 truncate">
|
<span className="flex-1 truncate">
|
||||||
{human.email_prefix}
|
{human.email_prefix}
|
||||||
</span>
|
</span>
|
||||||
<UserPlus className="size-3.5 text-white/30" />
|
<UserPlus className="text-muted-foreground size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -246,7 +248,7 @@ export function StreamMembersOverlay({
|
|||||||
{isCreator &&
|
{isCreator &&
|
||||||
visibility.mode === 'custom' &&
|
visibility.mode === 'custom' &&
|
||||||
availableToAdd.length === 0 && (
|
availableToAdd.length === 0 && (
|
||||||
<p className="mt-4 text-center text-xs text-white/30">
|
<p className="text-muted-foreground mt-4 text-center text-xs">
|
||||||
<Users className="mr-1 inline size-3" />
|
<Users className="mr-1 inline size-3" />
|
||||||
Everyone in the network is already a member
|
Everyone in the network is already a member
|
||||||
</p>
|
</p>
|
||||||
@@ -275,8 +277,8 @@ function VisibilityPill({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors',
|
'flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors',
|
||||||
active
|
active
|
||||||
? 'bg-white/10 text-white/90'
|
? 'bg-background text-foreground shadow-sm'
|
||||||
: 'text-white/50 hover:text-white/80',
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function TopBar({
|
|||||||
<div className="drag-region flex flex-row px-2 gap-1 items-center">
|
<div className="drag-region flex flex-row px-2 gap-1 items-center">
|
||||||
<WindowControls />
|
<WindowControls />
|
||||||
|
|
||||||
<Breadcrumb className="no-drag rounded-full bg-black/30 backdrop-blur-sm px-3 py-1 mx-auto">
|
<Breadcrumb className="no-drag bg-card/70 border-border mx-auto rounded-full border px-3 py-1 backdrop-blur-sm">
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
{streamParticle && (
|
{streamParticle && (
|
||||||
<>
|
<>
|
||||||
@@ -139,7 +139,7 @@ export function TopBar({
|
|||||||
size="sm"
|
size="sm"
|
||||||
avatarObjectId={display.avatarObjectId}
|
avatarObjectId={display.avatarObjectId}
|
||||||
initials={display.initials}
|
initials={display.initials}
|
||||||
fallbackClassName="bg-red-500/30 text-[8px] text-red-200"
|
fallbackClassName="bg-red-500/30 text-[8px] text-red-700 dark:text-red-200"
|
||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{display.email}</TooltipContent>
|
<TooltipContent>{display.email}</TooltipContent>
|
||||||
@@ -147,12 +147,14 @@ export function TopBar({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</AvatarGroup>
|
</AvatarGroup>
|
||||||
<span className="text-xs font-medium text-red-200">Join</span>
|
<span className="text-xs font-medium text-red-600 dark:text-red-200">
|
||||||
|
Join
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isStreamOpen(streamParticle) && (
|
{!isStreamOpen(streamParticle) && (
|
||||||
<span className="no-drag text-muted-foreground flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs backdrop-blur-sm">
|
<span className="no-drag text-muted-foreground bg-muted border-border flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs backdrop-blur-sm">
|
||||||
<CircleCheckBig className="size-3" />
|
<CircleCheckBig className="size-3" />
|
||||||
Closed
|
Closed
|
||||||
</span>
|
</span>
|
||||||
@@ -280,16 +282,16 @@ function MembersIndicator({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="no-drag flex items-center gap-1.5 rounded-full bg-white/5 px-2 py-1 text-xs text-white/70 backdrop-blur-sm transition-colors hover:bg-white/10"
|
className="no-drag bg-card/70 text-muted-foreground border-border hover:bg-accent flex items-center gap-1.5 rounded-full border px-2 py-1 text-xs backdrop-blur-sm transition-colors"
|
||||||
>
|
>
|
||||||
{isNetworkWide ? (
|
{isNetworkWide ? (
|
||||||
<>
|
<>
|
||||||
<Globe className="size-3 text-white/50" />
|
<Globe className="text-muted-foreground size-3" />
|
||||||
<span>Everyone</span>
|
<span>Everyone</span>
|
||||||
</>
|
</>
|
||||||
) : isPrivate ? (
|
) : isPrivate ? (
|
||||||
<>
|
<>
|
||||||
<Lock className="size-3 text-white/50" />
|
<Lock className="text-muted-foreground size-3" />
|
||||||
<span>Private</span>
|
<span>Private</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -306,7 +308,7 @@ function MembersIndicator({
|
|||||||
))}
|
))}
|
||||||
</AvatarGroup>
|
</AvatarGroup>
|
||||||
{overflow > 0 && (
|
{overflow > 0 && (
|
||||||
<span className="text-white/50">+{overflow}</span>
|
<span className="text-muted-foreground">+{overflow}</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -212,11 +212,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
platform.autoplay.dismiss();
|
platform.autoplay.dismiss();
|
||||||
});
|
});
|
||||||
|
|
||||||
const userId = useAuthStore((s) => s.user?.id);
|
const { mode, toggle: toggleViewMode } = useStreamViewMode();
|
||||||
const { mode, toggle: toggleViewMode } = useStreamViewMode(
|
|
||||||
streamParticle,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
children,
|
children,
|
||||||
@@ -385,7 +381,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
|
|
||||||
if (children.length === 0) {
|
if (children.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-4 bg-black text-white">
|
<div className="bg-background flex h-full flex-col items-center justify-center gap-4">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
No particles in this stream yet
|
No particles in this stream yet
|
||||||
</p>
|
</p>
|
||||||
@@ -453,6 +449,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
paused={paused}
|
paused={paused}
|
||||||
onEnded={handleParticleEnded}
|
onEnded={handleParticleEnded}
|
||||||
onProgress={setProgress}
|
onProgress={setProgress}
|
||||||
|
immersive
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
@@ -463,15 +460,15 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-black">
|
<div className="bg-background flex h-screen overflow-hidden">
|
||||||
{/* Stream chrome — immersive playback column */}
|
{/* Stream chrome — immersive playback column */}
|
||||||
<div
|
<div
|
||||||
className="relative flex min-w-0 flex-1 flex-col overflow-hidden bg-black text-white [--stream-safe-top:4rem] [--stream-safe-bottom:5rem]"
|
className="bg-background relative flex min-w-0 flex-1 flex-col overflow-hidden [--stream-safe-top:4rem] [--stream-safe-bottom:5rem]"
|
||||||
onMouseMove={handleMouseActivity}
|
onMouseMove={handleMouseActivity}
|
||||||
onMouseLeave={() => setShowControls(false)}
|
onMouseLeave={() => setShowControls(false)}
|
||||||
>
|
>
|
||||||
{/* Top gradient safe zone */}
|
{/* Top gradient safe zone */}
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b from-black/60 to-transparent" />
|
<div className="from-background/80 pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b to-transparent" />
|
||||||
|
|
||||||
{/* TopBar — always visible */}
|
{/* TopBar — always visible */}
|
||||||
<div className="z-10 absolute left-0 right-0 pt-2">
|
<div className="z-10 absolute left-0 right-0 pt-2">
|
||||||
@@ -491,7 +488,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
|
|
||||||
<div className="pointer-events-none absolute right-4 top-14 z-20 flex flex-col items-end gap-1.5">
|
<div className="pointer-events-none absolute right-4 top-14 z-20 flex flex-col items-end gap-1.5">
|
||||||
{fastPlayback && (
|
{fastPlayback && (
|
||||||
<div className="rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm">
|
<div className="bg-card/70 text-foreground border-border rounded-full border px-2.5 py-1 text-xs font-medium backdrop-blur-sm">
|
||||||
1.5x
|
1.5x
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -500,7 +497,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={resume}
|
onClick={resume}
|
||||||
title="Resume (or press Space)"
|
title="Resume (or press Space)"
|
||||||
className="pointer-events-auto flex items-center gap-1.5 rounded-full bg-black/40 px-2.5 py-1 text-xs font-medium text-white/70 backdrop-blur-sm transition-colors hover:bg-black/60 hover:text-white"
|
className="bg-card/70 text-muted-foreground border-border hover:bg-accent hover:text-foreground pointer-events-auto flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium backdrop-blur-sm transition-colors"
|
||||||
>
|
>
|
||||||
<Play className="size-3 fill-current" />
|
<Play className="size-3 fill-current" />
|
||||||
Paused
|
Paused
|
||||||
@@ -544,7 +541,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Bottom gradient safe zone for keyboard hints */}
|
{/* Bottom gradient safe zone for keyboard hints */}
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t from-black/60 to-transparent" />
|
<div className="from-background/80 pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t to-transparent" />
|
||||||
|
|
||||||
{/* BottomBar — pinned visible while browsing, mouse-activity in player */}
|
{/* BottomBar — pinned visible while browsing, mouse-activity in player */}
|
||||||
<BottomBar
|
<BottomBar
|
||||||
@@ -572,7 +569,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
{/* Browse sidebar — a separate chat-like panel beside the stream */}
|
{/* Browse sidebar — a separate chat-like panel beside the stream */}
|
||||||
{mode === 'list' && (
|
{mode === 'list' && (
|
||||||
<StreamListSidebar
|
<StreamListSidebar
|
||||||
streamName={streamParticle.properties.name}
|
|
||||||
items={children}
|
items={children}
|
||||||
networkId={networkId}
|
networkId={networkId}
|
||||||
currentIndex={currentIndex}
|
currentIndex={currentIndex}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import { useFixedDwell } from '@/hooks/use-fixed-dwell';
|
|||||||
import { useLiveDraftField } from '@/hooks/use-live-draft-field';
|
import { useLiveDraftField } from '@/hooks/use-live-draft-field';
|
||||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||||
import { resolveHumanDisplay } from '@/lib/humans';
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
|
import { isTypingTarget } from '@/lib/keyboard';
|
||||||
|
import { KeyHint } from '@/components/key-hint';
|
||||||
|
|
||||||
type TaskParticle = Extract<Particle, { type: 'task' }>;
|
type TaskParticle = Extract<Particle, { type: 'task' }>;
|
||||||
|
|
||||||
@@ -37,11 +39,28 @@ interface TaskParticleViewProps {
|
|||||||
paused: boolean;
|
paused: boolean;
|
||||||
onEnded: () => void;
|
onEnded: () => void;
|
||||||
onProgress?: (ratio: number) => void;
|
onProgress?: (ratio: number) => void;
|
||||||
|
/**
|
||||||
|
* Enable focus mode while a field is focused: dim and blur the surrounding
|
||||||
|
* stream, and trap stream keys (Escape leaves the editor, navigation keys
|
||||||
|
* stay put). Off in the standalone leaf view, which has no stream chrome.
|
||||||
|
*/
|
||||||
|
immersive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DWELL_DURATION_S = 8;
|
const DWELL_DURATION_S = 8;
|
||||||
const UNASSIGNED = 'unassigned';
|
const UNASSIGNED = 'unassigned';
|
||||||
|
|
||||||
|
// Stream-navigation keys to swallow while editing so they can't pull focus to
|
||||||
|
// another particle (only when focus isn't already in a text field).
|
||||||
|
const NAV_KEYS = new Set([
|
||||||
|
'ArrowLeft',
|
||||||
|
'ArrowRight',
|
||||||
|
'ArrowUp',
|
||||||
|
'ArrowDown',
|
||||||
|
'l',
|
||||||
|
'L',
|
||||||
|
]);
|
||||||
|
|
||||||
function useParticleDocPath(
|
function useParticleDocPath(
|
||||||
containerPath: ParticlePath,
|
containerPath: ParticlePath,
|
||||||
particleId: string,
|
particleId: string,
|
||||||
@@ -56,6 +75,7 @@ export function TaskParticleView({
|
|||||||
paused,
|
paused,
|
||||||
onEnded,
|
onEnded,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
immersive = false,
|
||||||
}: TaskParticleViewProps) {
|
}: TaskParticleViewProps) {
|
||||||
const { networkId } = parseParticlePath(containerPath);
|
const { networkId } = parseParticlePath(containerPath);
|
||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
@@ -74,6 +94,33 @@ export function TaskParticleView({
|
|||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
useSuspendPlayback(editing, `task-edit-${particle.id}`);
|
useSuspendPlayback(editing, `task-edit-${particle.id}`);
|
||||||
|
|
||||||
|
const exitFocus = useCallback(() => {
|
||||||
|
(document.activeElement as HTMLElement | null)?.blur();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// While editing, the card acts like the app's other focus overlays: a
|
||||||
|
// capture-phase listener consumes Escape (which blurs the field — flushing
|
||||||
|
// the draft and resuming playback) and swallows stream-navigation keys, so
|
||||||
|
// the stream's own handlers never see them. Self-contained, so no editing
|
||||||
|
// state has to be threaded back up to the stream.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!immersive || !editing) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
exitFocus();
|
||||||
|
} else if (NAV_KEYS.has(e.key) && !isTypingTarget(e)) {
|
||||||
|
// Arrows still move the caret inside text fields; only block them when
|
||||||
|
// focus is on a non-text control (checkbox, assignee select).
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown, { capture: true });
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener('keydown', onKeyDown, { capture: true });
|
||||||
|
}, [immersive, editing, exitFocus]);
|
||||||
|
|
||||||
useFixedDwell({
|
useFixedDwell({
|
||||||
id: particle.id,
|
id: particle.id,
|
||||||
durationS: DWELL_DURATION_S,
|
durationS: DWELL_DURATION_S,
|
||||||
@@ -165,9 +212,21 @@ export function TaskParticleView({
|
|||||||
const doneCount = checklist.filter((item) => item.done).length;
|
const doneCount = checklist.filter((item) => item.done).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
<div className="flex h-full w-full items-center justify-center px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
||||||
|
{immersive && editing && (
|
||||||
|
<div
|
||||||
|
className="animate-in fade-in-0 fixed inset-0 z-30 bg-scrim/50 backdrop-blur-md duration-200"
|
||||||
|
onMouseDown={exitFocus}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="scrollbar-card flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-5 overflow-y-auto overscroll-contain rounded bg-white/10 p-[var(--message-card-padding)] backdrop-blur-md"
|
className={cn(
|
||||||
|
'scrollbar-card bg-card text-card-foreground border-border flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-5 overflow-y-auto overscroll-contain rounded border p-[var(--message-card-padding)] transition-shadow',
|
||||||
|
immersive &&
|
||||||
|
editing &&
|
||||||
|
'relative z-40 shadow-2xl shadow-black/50 ring-1 ring-border',
|
||||||
|
)}
|
||||||
onFocusCapture={() => setEditing(true)}
|
onFocusCapture={() => setEditing(true)}
|
||||||
onBlurCapture={(e) => {
|
onBlurCapture={(e) => {
|
||||||
if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false);
|
if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false);
|
||||||
@@ -177,7 +236,7 @@ export function TaskParticleView({
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={done}
|
checked={done}
|
||||||
onCheckedChange={(checked) => handleToggleDone(checked === true)}
|
onCheckedChange={(checked) => handleToggleDone(checked === true)}
|
||||||
className="mt-1.5 size-5 rounded-full border-white/40 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
|
className="mt-1.5 size-5 rounded-full border-muted-foreground/40 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
|
||||||
aria-label={done ? 'Mark task as not done' : 'Mark task as done'}
|
aria-label={done ? 'Mark task as not done' : 'Mark task as done'}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@@ -187,8 +246,8 @@ export function TaskParticleView({
|
|||||||
onBlur={titleField.onBlur}
|
onBlur={titleField.onBlur}
|
||||||
placeholder="Task title"
|
placeholder="Task title"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full bg-transparent text-2xl font-semibold text-white outline-none placeholder:text-white/30',
|
'text-foreground placeholder:text-muted-foreground w-full bg-transparent text-2xl font-semibold outline-none',
|
||||||
done && 'text-white/50 line-through',
|
done && 'text-muted-foreground line-through',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,12 +258,12 @@ export function TaskParticleView({
|
|||||||
onFocus={notesField.onFocus}
|
onFocus={notesField.onFocus}
|
||||||
onBlur={notesField.onBlur}
|
onBlur={notesField.onBlur}
|
||||||
placeholder="Add notes…"
|
placeholder="Add notes…"
|
||||||
className="min-h-16 resize-none border-none bg-transparent p-0 text-sm text-white/80 shadow-none placeholder:text-white/30 focus-visible:ring-0 dark:bg-transparent"
|
className="text-foreground placeholder:text-muted-foreground min-h-16 resize-none border-none bg-transparent p-0 text-sm shadow-none focus-visible:ring-0 dark:bg-transparent"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{checklist.length > 0 && (
|
{checklist.length > 0 && (
|
||||||
<span className="text-xs text-white/40">
|
<span className="text-muted-foreground text-xs">
|
||||||
{doneCount} / {checklist.length} done
|
{doneCount} / {checklist.length} done
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -227,10 +286,7 @@ export function TaskParticleView({
|
|||||||
value={assigned_to ?? UNASSIGNED}
|
value={assigned_to ?? UNASSIGNED}
|
||||||
onValueChange={handleAssign}
|
onValueChange={handleAssign}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger size="sm" className="w-fit gap-2">
|
||||||
size="sm"
|
|
||||||
className="w-fit gap-2 border-white/15 bg-white/5 text-white/80"
|
|
||||||
>
|
|
||||||
{assigned_to && assignee.exists && (
|
{assigned_to && assignee.exists && (
|
||||||
<HumanAvatar
|
<HumanAvatar
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -251,6 +307,16 @@ export function TaskParticleView({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{immersive && editing && (
|
||||||
|
<KeyHint
|
||||||
|
keys="Esc"
|
||||||
|
onClick={exitFocus}
|
||||||
|
title="Finish editing (or press Esc)"
|
||||||
|
className="fixed bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 z-40 -translate-x-1/2 text-xs text-white/60"
|
||||||
|
>
|
||||||
|
to finish
|
||||||
|
</KeyHint>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -276,7 +342,7 @@ function ChecklistItemRow({
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={item.done}
|
checked={item.done}
|
||||||
onCheckedChange={(checked) => onToggle(checked === true)}
|
onCheckedChange={(checked) => onToggle(checked === true)}
|
||||||
className="border-white/30 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
|
className="border-muted-foreground/40 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
|
||||||
aria-label={
|
aria-label={
|
||||||
item.done ? 'Mark subtask as not done' : 'Mark subtask as done'
|
item.done ? 'Mark subtask as not done' : 'Mark subtask as done'
|
||||||
}
|
}
|
||||||
@@ -288,14 +354,14 @@ function ChecklistItemRow({
|
|||||||
onBlur={textField.onBlur}
|
onBlur={textField.onBlur}
|
||||||
placeholder="Subtask"
|
placeholder="Subtask"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full bg-transparent text-sm text-white/90 outline-none placeholder:text-white/30',
|
'text-foreground placeholder:text-muted-foreground w-full bg-transparent text-sm outline-none',
|
||||||
item.done && 'text-white/40 line-through',
|
item.done && 'text-muted-foreground line-through',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
className="text-white/30 opacity-0 transition-opacity hover:text-white/70 group-hover/item:opacity-100"
|
className="text-muted-foreground hover:text-foreground opacity-0 transition-opacity group-hover/item:opacity-100"
|
||||||
aria-label="Remove subtask"
|
aria-label="Remove subtask"
|
||||||
>
|
>
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
@@ -316,7 +382,7 @@ function AddChecklistItemRow({ onAdd }: { onAdd: (text: string) => void }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<Plus className="size-4 text-white/30" />
|
<Plus className="text-muted-foreground size-4" />
|
||||||
<input
|
<input
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
@@ -328,7 +394,7 @@ function AddChecklistItemRow({ onAdd }: { onAdd: (text: string) => void }) {
|
|||||||
}}
|
}}
|
||||||
onBlur={submit}
|
onBlur={submit}
|
||||||
placeholder="Add subtask…"
|
placeholder="Add subtask…"
|
||||||
className="w-full bg-transparent text-sm text-white/70 outline-none placeholder:text-white/30"
|
className="text-foreground placeholder:text-muted-foreground w-full bg-transparent text-sm outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function TextParticleView({
|
|||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}}
|
}}
|
||||||
title="Edit"
|
title="Edit"
|
||||||
className="absolute bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 -translate-x-1/2 z-30 flex items-center gap-1.5 rounded-full bg-black/40 px-3 py-1.5 text-xs text-white/70 backdrop-blur-sm transition-colors hover:bg-black/60 hover:text-white"
|
className="bg-card/70 text-muted-foreground border-border hover:bg-accent hover:text-foreground absolute bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 z-30 flex -translate-x-1/2 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs backdrop-blur-sm transition-colors"
|
||||||
>
|
>
|
||||||
<Pencil className="size-3.5" />
|
<Pencil className="size-3.5" />
|
||||||
Edit
|
Edit
|
||||||
@@ -130,7 +130,7 @@ export function TextParticleView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const editedLabel = particle.properties.edited_at && (
|
const editedLabel = particle.properties.edited_at && (
|
||||||
<span className="text-xs text-white/40">
|
<span className="text-muted-foreground text-xs">
|
||||||
edited <RelativeTimestamp date={particle.properties.edited_at} />
|
edited <RelativeTimestamp date={particle.properties.edited_at} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -146,7 +146,7 @@ export function TextParticleView({
|
|||||||
// Mode 1: bare URLs only — show link cards centered
|
// Mode 1: bare URLs only — show link cards centered
|
||||||
if (linksOnly && !hasAttachments) {
|
if (linksOnly && !hasAttachments) {
|
||||||
return (
|
return (
|
||||||
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
<div className="group relative flex h-full w-full items-center justify-center px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
||||||
<LinkPreviews entries={linkPreviews} />
|
<LinkPreviews entries={linkPreviews} />
|
||||||
{editedLabel && (
|
{editedLabel && (
|
||||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2">
|
<div className="absolute bottom-6 left-1/2 -translate-x-1/2">
|
||||||
@@ -167,10 +167,10 @@ export function TextParticleView({
|
|||||||
) {
|
) {
|
||||||
const style = getImmersiveTextStyle(content.length);
|
const style = getImmersiveTextStyle(content.length);
|
||||||
return (
|
return (
|
||||||
<div className="group relative flex h-full w-full flex-col items-center justify-center gap-4 bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
<div className="group relative flex h-full w-full flex-col items-center justify-center gap-4 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'max-w-2xl break-words text-center leading-relaxed text-white select-text cursor-text',
|
'text-foreground max-w-2xl cursor-text select-text break-words text-center leading-relaxed',
|
||||||
style.size,
|
style.size,
|
||||||
style.weight,
|
style.weight,
|
||||||
)}
|
)}
|
||||||
@@ -186,8 +186,8 @@ export function TextParticleView({
|
|||||||
|
|
||||||
// Mode 3: card layout
|
// Mode 3: card layout
|
||||||
return (
|
return (
|
||||||
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
<div className="group relative flex h-full w-full items-center justify-center px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
||||||
<div className="scrollbar-card flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-4 overflow-y-auto overscroll-contain rounded bg-white/10 p-[var(--message-card-padding)] backdrop-blur-md">
|
<div className="scrollbar-card bg-card text-card-foreground border-border flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-4 overflow-y-auto overscroll-contain rounded border p-[var(--message-card-padding)]">
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
key={content}
|
key={content}
|
||||||
value={content}
|
value={content}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function TextReactionInput({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="flex items-center gap-1 rounded-full bg-black/60 py-1 pl-3 pr-1 shadow-lg ring-1 ring-white/15 backdrop-blur-md"
|
className="bg-popover/90 text-popover-foreground border-border flex items-center gap-1 rounded-full border py-1 pl-3 pr-1 shadow-lg backdrop-blur-md"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -74,12 +74,14 @@ export function TextReactionInput({
|
|||||||
}}
|
}}
|
||||||
placeholder="Quick reply…"
|
placeholder="Quick reply…"
|
||||||
maxLength={MAX_LENGTH}
|
maxLength={MAX_LENGTH}
|
||||||
className="w-24 bg-transparent text-sm text-white outline-none placeholder:text-white/40"
|
className="text-foreground placeholder:text-muted-foreground w-24 bg-transparent text-sm outline-none"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-w-[1.5ch] text-right text-[10px] tabular-nums',
|
'min-w-[1.5ch] text-right text-[10px] tabular-nums',
|
||||||
remaining <= 8 ? 'text-amber-300/80' : 'text-white/30',
|
remaining <= 8
|
||||||
|
? 'text-amber-600 dark:text-amber-400'
|
||||||
|
: 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{remaining}
|
{remaining}
|
||||||
@@ -91,8 +93,8 @@ export function TextReactionInput({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'ml-1 flex size-6 items-center justify-center rounded-full transition-colors',
|
'ml-1 flex size-6 items-center justify-center rounded-full transition-colors',
|
||||||
canSubmit
|
canSubmit
|
||||||
? 'bg-white/20 text-white hover:bg-white/30'
|
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||||
: 'text-white/30',
|
: 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
aria-label="Send reaction"
|
aria-label="Send reaction"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -12,11 +12,16 @@ import {
|
|||||||
Volume2,
|
Volume2,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Camera,
|
Camera,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
Monitor,
|
||||||
|
Palette,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { HumanAvatar } from '@/components/human-avatar';
|
import { HumanAvatar } from '@/components/human-avatar';
|
||||||
import { AvatarEditDialog } from '@/features/settings/avatar-edit-dialog';
|
import { AvatarEditDialog } from '@/features/settings/avatar-edit-dialog';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
import { WindowControls } from '@/components/window-controls';
|
import { WindowControls } from '@/components/window-controls';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Muted } from '@/components/ui/typography';
|
import { Muted } from '@/components/ui/typography';
|
||||||
@@ -24,6 +29,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
|||||||
import { CopyableEmail } from '@/components/copyable-email';
|
import { CopyableEmail } from '@/components/copyable-email';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { useSoundEffectsStore } from '@/stores/sound-effects-store';
|
import { useSoundEffectsStore } from '@/stores/sound-effects-store';
|
||||||
|
import { useThemeStore, type ThemeMode } from '@/stores/theme-store';
|
||||||
import { apiClient } from '@/api/client';
|
import { apiClient } from '@/api/client';
|
||||||
import { logError, toUserMessage } from '@/lib/errors';
|
import { logError, toUserMessage } from '@/lib/errors';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -80,6 +86,50 @@ function SettingsGroup({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const THEME_OPTIONS: {
|
||||||
|
value: ThemeMode;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}[] = [
|
||||||
|
{ value: 'system', label: 'System', icon: <Monitor className="size-3.5" /> },
|
||||||
|
{ value: 'light', label: 'Light', icon: <Sun className="size-3.5" /> },
|
||||||
|
{ value: 'dark', label: 'Dark', icon: <Moon className="size-3.5" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
function ThemeSetting() {
|
||||||
|
const mode = useThemeStore((s) => s.mode);
|
||||||
|
const setMode = useThemeStore((s) => s.setMode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center gap-3 px-4 py-3">
|
||||||
|
<span className="text-muted-foreground flex size-5 items-center justify-center">
|
||||||
|
<Palette className="size-4" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 text-sm font-medium">Theme</span>
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
size="sm"
|
||||||
|
value={mode}
|
||||||
|
onValueChange={(value) => value && setMode(value as ThemeMode)}
|
||||||
|
className="border-border bg-muted/50 border p-0.5"
|
||||||
|
spacing={2}
|
||||||
|
>
|
||||||
|
{THEME_OPTIONS.map((option) => (
|
||||||
|
<ToggleGroupItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
aria-label={option.label}
|
||||||
|
className="data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm gap-1.5 rounded-md px-2.5 text-xs"
|
||||||
|
>
|
||||||
|
{option.icon}
|
||||||
|
{option.label}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
@@ -145,13 +195,14 @@ export default function SettingsPage() {
|
|||||||
aria-label="Change profile picture"
|
aria-label="Change profile picture"
|
||||||
>
|
>
|
||||||
<HumanAvatar
|
<HumanAvatar
|
||||||
size="lg"
|
className="size-16"
|
||||||
avatarObjectId={user?.avatar_object_id}
|
avatarObjectId={user?.avatar_object_id}
|
||||||
initials={initials}
|
initials={initials}
|
||||||
fallbackClassName="bg-primary/10 text-primary font-medium"
|
fallbackClassName="bg-primary/10 text-primary text-xl font-medium"
|
||||||
/>
|
/>
|
||||||
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
|
<span className="bg-scrim/40 absolute inset-0 flex items-center justify-center rounded-full opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
<Camera className="size-4 text-white" />
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -164,6 +215,12 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
<SettingsGroup title="Appearance">
|
||||||
|
<ThemeSetting />
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<Separator className="mt-4" />
|
||||||
|
|
||||||
<SettingsGroup title="Notifications">
|
<SettingsGroup title="Notifications">
|
||||||
<div className="flex w-full items-center gap-3 px-4 py-3">
|
<div className="flex w-full items-center gap-3 px-4 py-3">
|
||||||
<span className="text-muted-foreground flex size-5 items-center justify-center">
|
<span className="text-muted-foreground flex size-5 items-center justify-center">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { transferFiles } from '@/lib/data-transfer';
|
||||||
|
|
||||||
interface UseFileInputOptions {
|
interface UseFileInputOptions {
|
||||||
onFilesSelected: (files: File[]) => void;
|
onFilesSelected: (files: File[]) => void;
|
||||||
@@ -48,15 +49,20 @@ export function useFileInput({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
|
||||||
// Capture phase so file pastes always become attachments — ProseMirror
|
// Capture phase so file pastes always become attachments — the markdown
|
||||||
// would otherwise inline pasted images as ephemeral blob: URLs. Mixed
|
// editor would otherwise inline a pasted image as an ephemeral blob: URL.
|
||||||
// clipboards (e.g. Excel/Word ship an image rendition alongside the text)
|
|
||||||
// must still paste as text, so only file-only pastes are intercepted.
|
|
||||||
const handlePaste = (e: ClipboardEvent) => {
|
const handlePaste = (e: ClipboardEvent) => {
|
||||||
const data = e.clipboardData;
|
const data = e.clipboardData;
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const files = Array.from(data.files);
|
|
||||||
if (files.length === 0 || data.types.includes('text/plain')) return;
|
// Actual text means "paste as text", even when the clipboard also ships
|
||||||
|
// an image rendition (Excel/Word, or "copy image" from a page with alt
|
||||||
|
// text). Test the payload, not the advertised types: image pastes often
|
||||||
|
// list an *empty* text/plain entry that must not block the attachment.
|
||||||
|
if (data.getData('text/plain').trim().length > 0) return;
|
||||||
|
|
||||||
|
const files = transferFiles(data);
|
||||||
|
if (files.length === 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onFilesRef.current(files);
|
onFilesRef.current(files);
|
||||||
@@ -105,7 +111,8 @@ export function useFileInput({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dragCountRef.current = 0;
|
dragCountRef.current = 0;
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
const files = Array.from(e.dataTransfer.files);
|
|
||||||
|
const files = transferFiles(e.dataTransfer);
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
onFilesRef.current(files);
|
onFilesRef.current(files);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,12 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import type { Particle } from '@/api/types';
|
|
||||||
|
|
||||||
export type StreamViewMode = 'player' | 'list';
|
export type StreamViewMode = 'player' | 'list';
|
||||||
|
|
||||||
function decideMode(
|
export function useStreamViewMode(): {
|
||||||
streamParticle: Particle & { type: 'stream' },
|
mode: StreamViewMode;
|
||||||
userId: string | undefined,
|
toggle: () => void;
|
||||||
): StreamViewMode {
|
} {
|
||||||
const marker = userId ? streamParticle.playback_markers?.[userId] : undefined;
|
const [mode, setMode] = useState<StreamViewMode>('player');
|
||||||
const lastChildAt = streamParticle.last_child_created_at;
|
|
||||||
const caughtUp =
|
|
||||||
!!marker && !!lastChildAt && lastChildAt.getTime() <= marker.getTime();
|
|
||||||
return caughtUp ? 'list' : 'player';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which mode a stream opens in: the player (autoplay catch-up) when there's
|
|
||||||
* unseen content, the browsable list when the user is fully caught up.
|
|
||||||
* Decided once on entry from the playback marker vs. the stream's last
|
|
||||||
* activity — browsing afterwards advances the marker, but the mode only
|
|
||||||
* changes via the user's toggle.
|
|
||||||
*/
|
|
||||||
export function useStreamViewMode(
|
|
||||||
streamParticle: Particle & { type: 'stream' },
|
|
||||||
userId: string | undefined,
|
|
||||||
): { mode: StreamViewMode; toggle: () => void } {
|
|
||||||
const [mode, setMode] = useState<StreamViewMode>(() =>
|
|
||||||
decideMode(streamParticle, userId),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Re-decide when navigating between streams without an unmount.
|
|
||||||
const [prevStreamId, setPrevStreamId] = useState(streamParticle.id);
|
|
||||||
if (prevStreamId !== streamParticle.id) {
|
|
||||||
setPrevStreamId(streamParticle.id);
|
|
||||||
setMode(decideMode(streamParticle, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
setMode((prev) => (prev === 'player' ? 'list' : 'player'));
|
setMode((prev) => (prev === 'player' ? 'list' : 'player'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ export const MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
|
|||||||
/** Maximum number of file attachments per particle. */
|
/** Maximum number of file attachments per particle. */
|
||||||
export const MAX_ATTACHMENTS = 10;
|
export const MAX_ATTACHMENTS = 10;
|
||||||
|
|
||||||
|
/** Maximum recommended duration for a media (audio/video) recording, in seconds. */
|
||||||
|
export const RECORDING_MAX_DURATION_SECONDS = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum time the record key must be held to count as hold-to-record (vs. a
|
||||||
|
* quick tap that toggles), in milliseconds.
|
||||||
|
*/
|
||||||
|
export const HOLD_THRESHOLD_MS = 250;
|
||||||
|
|
||||||
export const SUPPORT_EMAIL = 'team@flowylabs.ai';
|
export const SUPPORT_EMAIL = 'team@flowylabs.ai';
|
||||||
|
|
||||||
export const PRIVACY_URL = 'https://flowylabs.ai/llink/privacy';
|
export const PRIVACY_URL = 'https://flowylabs.ai/llink/privacy';
|
||||||
|
|||||||
@@ -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 { createRoot } from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { initSentryRenderer } from '@/lib/sentry';
|
import { initSentryRenderer } from '@/lib/sentry';
|
||||||
|
import '@/stores/theme-store';
|
||||||
import './styles/globals.css';
|
import './styles/globals.css';
|
||||||
|
|
||||||
initSentryRenderer();
|
initSentryRenderer();
|
||||||
|
|||||||
@@ -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-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-scrim: var(--scrim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Warm Sand / Amber palette. Neutrals carry a low-chroma warm hue (~60-85°)
|
||||||
|
so surfaces read as paper/espresso rather than flat gray, with a burnt-amber
|
||||||
|
primary as the brand accent. */
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.985 0.006 83);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.255 0.012 64);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(0.998 0.003 90);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.255 0.012 64);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(0.998 0.003 90);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.255 0.012 64);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.62 0.128 62);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.99 0.006 90);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.945 0.014 78);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.32 0.014 60);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.945 0.012 78);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.535 0.02 62);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.92 0.03 70);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.3 0.035 55);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.9 0.014 75);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.9 0.014 75);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.62 0.128 62);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.66 0.14 60);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.7 0.12 95);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.58 0.09 45);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.75 0.13 80);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.6 0.11 30);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.965 0.01 80);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.255 0.012 64);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.62 0.128 62);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.99 0.006 90);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.92 0.03 70);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.3 0.035 55);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.9 0.014 75);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.62 0.128 62);
|
||||||
|
--scrollbar-thumb: oklch(0.45 0.03 60 / 25%);
|
||||||
|
--scrollbar-thumb-hover: oklch(0.45 0.03 60 / 40%);
|
||||||
|
--scrollbar-thumb-strong: oklch(0.45 0.03 60 / 35%);
|
||||||
|
--scrollbar-thumb-strong-hover: oklch(0.45 0.03 60 / 55%);
|
||||||
|
|
||||||
|
/* Modal/overlay backdrop. A dim scrim behind dialogs reads the same in both
|
||||||
|
themes (matching shadcn's default overlay), so it is intentionally
|
||||||
|
theme-independent. Use with an alpha modifier, e.g. bg-scrim/60. */
|
||||||
|
--scrim: oklch(0 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.19 0.012 58);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.955 0.008 85);
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.232 0.014 56);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.955 0.008 85);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.232 0.014 56);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.955 0.008 85);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.78 0.13 78);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.24 0.03 60);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.285 0.014 54);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.955 0.008 85);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.285 0.014 54);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.72 0.018 72);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.33 0.032 56);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.955 0.008 85);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(0.92 0.03 85 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(0.92 0.03 85 / 15%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.78 0.13 78);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.78 0.13 78);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.7 0.12 50);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.72 0.11 95);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.65 0.13 35);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.6 0.1 60);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.165 0.012 58);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.955 0.008 85);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.78 0.13 78);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.24 0.03 60);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.33 0.032 56);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.955 0.008 85);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(0.92 0.03 85 / 10%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.78 0.13 78);
|
||||||
|
--scrollbar-thumb: oklch(0.92 0.03 85 / 18%);
|
||||||
|
--scrollbar-thumb-hover: oklch(0.92 0.03 85 / 32%);
|
||||||
|
--scrollbar-thumb-strong: oklch(0.92 0.03 85 / 30%);
|
||||||
|
--scrollbar-thumb-strong-hover: oklch(0.92 0.03 85 / 50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -130,7 +147,7 @@
|
|||||||
/* Scrollbar styling */
|
/* Scrollbar styling */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: oklch(1 0 0 / 20%) transparent;
|
scrollbar-color: var(--scrollbar-thumb) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
@@ -143,12 +160,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: oklch(1 0 0 / 20%);
|
background: var(--scrollbar-thumb);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: oklch(1 0 0 / 35%);
|
background: var(--scrollbar-thumb-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Message cards: more prominent scrollbar than the subtle global default.
|
/* Message cards: more prominent scrollbar than the subtle global default.
|
||||||
@@ -160,13 +177,13 @@
|
|||||||
|
|
||||||
.scrollbar-card::-webkit-scrollbar-thumb,
|
.scrollbar-card::-webkit-scrollbar-thumb,
|
||||||
.scrollbar-card ::-webkit-scrollbar-thumb {
|
.scrollbar-card ::-webkit-scrollbar-thumb {
|
||||||
background: oklch(1 0 0 / 30%);
|
background: var(--scrollbar-thumb-strong);
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-card::-webkit-scrollbar-thumb:hover,
|
.scrollbar-card::-webkit-scrollbar-thumb:hover,
|
||||||
.scrollbar-card ::-webkit-scrollbar-thumb:hover {
|
.scrollbar-card ::-webkit-scrollbar-thumb:hover {
|
||||||
background: oklch(1 0 0 / 50%);
|
background: var(--scrollbar-thumb-strong-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Message layout: the compose editor and the posted card derive their widths
|
/* Message layout: the compose editor and the posted card derive their widths
|
||||||
@@ -186,3 +203,39 @@
|
|||||||
.no-drag {
|
.no-drag {
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Recording time-limit progress bar: a single CSS animation fills the bar
|
||||||
|
left→right over the recording duration (duration set inline), avoiding
|
||||||
|
per-frame React renders. */
|
||||||
|
@keyframes record-progress {
|
||||||
|
from {
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.record-progress {
|
||||||
|
transform: scaleX(0);
|
||||||
|
animation-name: record-progress;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulsing red glow around the recording overlay during the final warning window. */
|
||||||
|
@keyframes record-warning-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 2px var(--destructive),
|
||||||
|
inset 0 0 24px 0 oklch(0.6 0.24 27 / 0.25);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 3px var(--destructive),
|
||||||
|
inset 0 0 60px 0 oklch(0.6 0.24 27 / 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.record-warning-glow {
|
||||||
|
animation: record-warning-glow 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html class="dark">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/png" href="/icon.png" />
|
<link rel="icon" type="image/png" href="/icon.png" />
|
||||||
<title>llink</title>
|
<title>llink</title>
|
||||||
|
<script>
|
||||||
|
// Apply the stored theme before first paint to avoid a flash. Mirrors
|
||||||
|
// the logic in stores/theme-store.ts; defaults to dark.
|
||||||
|
try {
|
||||||
|
var m = localStorage.getItem('llink:theme');
|
||||||
|
var dark =
|
||||||
|
m === 'light'
|
||||||
|
? false
|
||||||
|
: m === 'system'
|
||||||
|
? matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
: true;
|
||||||
|
if (dark) document.documentElement.classList.add('dark');
|
||||||
|
} catch (e) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from '@/App';
|
import App from '@/App';
|
||||||
import { initSentryRenderer } from '@/lib/sentry';
|
import { initSentryRenderer } from '@/lib/sentry';
|
||||||
|
import '@/stores/theme-store';
|
||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
|
|
||||||
initSentryRenderer();
|
initSentryRenderer();
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ const config: ExpoConfig = {
|
|||||||
'Flowy uses your microphone to record voice messages.',
|
'Flowy uses your microphone to record voice messages.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'expo-image-picker',
|
||||||
|
{
|
||||||
|
photosPermission:
|
||||||
|
'Flowy uses your photo library to set your profile picture.',
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'expo-notifications',
|
'expo-notifications',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"expo-device": "~8.0.10",
|
"expo-device": "~8.0.10",
|
||||||
"expo-file-system": "~19.0.16",
|
"expo-file-system": "~19.0.16",
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
|
"expo-image-picker": "~17.0.8",
|
||||||
"expo-notifications": "~0.32.17",
|
"expo-notifications": "~0.32.17",
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { FileSystemUploadType, uploadAsync } from 'expo-file-system/legacy';
|
||||||
import { appConfig } from '@/config/env';
|
import { appConfig } from '@/config/env';
|
||||||
import { ApiError } from '@/lib/errors';
|
import { ApiError } from '@/lib/errors';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
@@ -136,6 +137,45 @@ class ApiClient {
|
|||||||
await this.requestVoid('PATCH', '/humans/me/settings', data);
|
await this.requestVoid('PATCH', '/humans/me/settings', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Avatar ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a new profile picture. The endpoint takes the raw image bytes as
|
||||||
|
* the request body (not multipart), so we stream the file directly via
|
||||||
|
* expo-file-system rather than the JSON `fetch` helper.
|
||||||
|
*/
|
||||||
|
async uploadAvatar(fileUri: string, mimeType: string): Promise<void> {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': mimeType };
|
||||||
|
if (this.token) {
|
||||||
|
headers['Authorization'] = `Bearer ${this.token}`;
|
||||||
|
}
|
||||||
|
const result = await uploadAsync(
|
||||||
|
`${this.baseUrl}/humans/me/avatar`,
|
||||||
|
fileUri,
|
||||||
|
{
|
||||||
|
httpMethod: 'PUT',
|
||||||
|
uploadType: FileSystemUploadType.BINARY_CONTENT,
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.status === 401) {
|
||||||
|
throw new ApiError(401, 'Unauthorized');
|
||||||
|
}
|
||||||
|
if (result.status < 200 || result.status >= 300) {
|
||||||
|
throw new ApiError(result.status, result.body || 'Avatar upload failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAvatar(): Promise<void> {
|
||||||
|
await this.requestVoid('DELETE', '/humans/me/avatar');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvatarDownloadUrl(objectId: string): Promise<string> {
|
||||||
|
const response = await this.fetch('GET', `/humans/avatar/${objectId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
return data.url;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Push notification tokens ---
|
// --- Push notification tokens ---
|
||||||
|
|
||||||
async registerPushToken(data: {
|
async registerPushToken(data: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const HumanSchema = z.object({
|
|||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
email_prefix: z.string(),
|
email_prefix: z.string(),
|
||||||
email_notifications_enabled: z.boolean(),
|
email_notifications_enabled: z.boolean(),
|
||||||
|
avatar_object_id: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Human = z.infer<typeof HumanSchema>;
|
export type Human = z.infer<typeof HumanSchema>;
|
||||||
@@ -145,14 +146,21 @@ export const TextPropertiesSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
|
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
|
||||||
|
|
||||||
export const QuestPropertiesSchema = z.object({
|
export const ChecklistItemSchema = z.object({
|
||||||
|
text: z.string(),
|
||||||
|
done: z.boolean(),
|
||||||
|
});
|
||||||
|
export type ChecklistItem = z.infer<typeof ChecklistItemSchema>;
|
||||||
|
|
||||||
|
export const TaskPropertiesSchema = z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
description: z.string(),
|
notes: z.string().optional(),
|
||||||
status: z.string().optional(),
|
checklist: z.array(ChecklistItemSchema).optional(),
|
||||||
// humanId
|
// humanId
|
||||||
assigned_to: z.string().optional(),
|
assigned_to: z.string().optional(),
|
||||||
|
done: z.boolean(),
|
||||||
});
|
});
|
||||||
export type QuestProperties = z.infer<typeof QuestPropertiesSchema>;
|
export type TaskProperties = z.infer<typeof TaskPropertiesSchema>;
|
||||||
|
|
||||||
export const PaperPropertiesSchema = z.object({
|
export const PaperPropertiesSchema = z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
@@ -193,7 +201,7 @@ export interface ParticlePropertiesMap {
|
|||||||
media: MediaProperties;
|
media: MediaProperties;
|
||||||
file: FileProperties;
|
file: FileProperties;
|
||||||
text: TextProperties;
|
text: TextProperties;
|
||||||
quest: QuestProperties;
|
task: TaskProperties;
|
||||||
paper: PaperProperties;
|
paper: PaperProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,8 +255,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [
|
|||||||
...TombstoneFields,
|
...TombstoneFields,
|
||||||
}),
|
}),
|
||||||
ParticleBaseSchema.extend({
|
ParticleBaseSchema.extend({
|
||||||
type: z.literal('quest'),
|
type: z.literal('task'),
|
||||||
properties: QuestPropertiesSchema,
|
properties: TaskPropertiesSchema,
|
||||||
|
reactions: ReactionsSchema,
|
||||||
...TombstoneFields,
|
...TombstoneFields,
|
||||||
}),
|
}),
|
||||||
ParticleBaseSchema.extend({
|
ParticleBaseSchema.extend({
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Text, View } from 'react-native';
|
import { Image, Text, View } from 'react-native';
|
||||||
import type { Human } from '@/api/types';
|
import type { Human } from '@/api/types';
|
||||||
|
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||||||
import { resolveHumanDisplay } from '@/lib/humans';
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type Size = 'xs' | 'sm' | 'md';
|
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
interface AvatarProps {
|
interface AvatarProps {
|
||||||
humanId: string | null | undefined;
|
humanId: string | null | undefined;
|
||||||
@@ -13,6 +14,8 @@ interface AvatarProps {
|
|||||||
online?: boolean;
|
online?: boolean;
|
||||||
/** Background ring used to separate stacked avatars from the chrome. */
|
/** Background ring used to separate stacked avatars from the chrome. */
|
||||||
stackBg?: string;
|
stackBg?: string;
|
||||||
|
/** Initials shown when the human can't be resolved (e.g. a group stream). */
|
||||||
|
fallbackInitials?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,12 +23,15 @@ const sizeMap: Record<Size, { box: string; text: string; ring: number }> = {
|
|||||||
xs: { box: 'h-6 w-6', text: 'text-[9px]', ring: 1.5 },
|
xs: { box: 'h-6 w-6', text: 'text-[9px]', ring: 1.5 },
|
||||||
sm: { box: 'h-9 w-9', text: 'text-xs', ring: 2 },
|
sm: { box: 'h-9 w-9', text: 'text-xs', ring: 2 },
|
||||||
md: { box: 'h-10 w-10', text: 'text-sm', ring: 2 },
|
md: { box: 'h-10 w-10', text: 'text-sm', ring: 2 },
|
||||||
|
lg: { box: 'h-16 w-16', text: 'text-xl', ring: 2.5 },
|
||||||
|
xl: { box: 'h-24 w-24', text: 'text-3xl', ring: 3 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initials avatar with optional online ring (green) and an optional outer
|
* Human avatar: renders the profile picture when one is set (resolved to a
|
||||||
* stack ring used to visually separate overlapping avatars on a busy chrome.
|
* signed URL via React Query), otherwise initials. Supports an optional online
|
||||||
* Matches desktop's avatar + presence pattern (`ring-2 ring-green-500`).
|
* ring (green) and an outer stack separator ring used to keep overlapping
|
||||||
|
* avatars distinct on busy chrome. Matches desktop's avatar + presence pattern.
|
||||||
*/
|
*/
|
||||||
export function Avatar({
|
export function Avatar({
|
||||||
humanId,
|
humanId,
|
||||||
@@ -33,15 +39,22 @@ export function Avatar({
|
|||||||
size = 'sm',
|
size = 'sm',
|
||||||
online = false,
|
online = false,
|
||||||
stackBg,
|
stackBg,
|
||||||
|
fallbackInitials,
|
||||||
className,
|
className,
|
||||||
}: AvatarProps) {
|
}: AvatarProps) {
|
||||||
const { initials } = resolveHumanDisplay(humanId, humans);
|
const display = resolveHumanDisplay(humanId, humans);
|
||||||
|
const human = humanId ? humans?.find((h) => h.id === humanId) : undefined;
|
||||||
|
const avatarUrl = useAvatarUrl(human?.avatar_object_id);
|
||||||
const dims = sizeMap[size];
|
const dims = sizeMap[size];
|
||||||
|
const initials =
|
||||||
|
display.exists || fallbackInitials === undefined
|
||||||
|
? display.initials
|
||||||
|
: fallbackInitials;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-black/15 items-center justify-center rounded-full',
|
'bg-black/15 items-center justify-center overflow-hidden rounded-full',
|
||||||
dims.box,
|
dims.box,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -52,9 +65,13 @@ export function Avatar({
|
|||||||
borderColor: online ? '#22c55e' : (stackBg ?? 'transparent'),
|
borderColor: online ? '#22c55e' : (stackBg ?? 'transparent'),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className={cn('text-white font-semibold', dims.text)}>
|
{avatarUrl ? (
|
||||||
{initials}
|
<Image source={{ uri: avatarUrl }} className="h-full w-full" />
|
||||||
</Text>
|
) : (
|
||||||
|
<Text className={cn('text-white font-semibold', dims.text)}>
|
||||||
|
{initials}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useCallback, useEffect, useState } from 'react';
|
||||||
import { Pressable, Text, View } from 'react-native';
|
import { Pressable, Text, View } from 'react-native';
|
||||||
import { Mic, Type as TypeIcon, Video as VideoIcon } from 'lucide-react-native';
|
import {
|
||||||
|
ListTodo,
|
||||||
|
Mic,
|
||||||
|
Type as TypeIcon,
|
||||||
|
Video as VideoIcon,
|
||||||
|
} from 'lucide-react-native';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { useCameraPermissions, useMicrophonePermissions } from 'expo-camera';
|
import { useCameraPermissions, useMicrophonePermissions } from 'expo-camera';
|
||||||
import { toast } from 'sonner-native';
|
import { toast } from 'sonner-native';
|
||||||
@@ -8,13 +13,18 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useEvent } from '@/hooks/use-event';
|
import { useEvent } from '@/hooks/use-event';
|
||||||
import { usePlaybackPauseStore } from '@/stores/playback-pause-store';
|
import { usePlaybackPauseStore } from '@/stores/playback-pause-store';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { createTextParticle, uploadMediaParticle } from '@/lib/upload';
|
import {
|
||||||
|
createTaskParticle,
|
||||||
|
createTextParticle,
|
||||||
|
uploadMediaParticle,
|
||||||
|
} from '@/lib/upload';
|
||||||
import type { ParticlePath } from '@/lib/particle-path';
|
import type { ParticlePath } from '@/lib/particle-path';
|
||||||
import {
|
import {
|
||||||
useStreamComposingBroadcastOptional,
|
useStreamComposingBroadcastOptional,
|
||||||
type ComposingMode,
|
type ComposingMode,
|
||||||
} from '@/features/stream-view/stream-presence-context';
|
} from '@/features/stream-view/stream-presence-context';
|
||||||
import { TextComposeModal } from './TextComposeModal';
|
import { TextComposeModal } from './TextComposeModal';
|
||||||
|
import { TaskComposeSheet } from './TaskComposeSheet';
|
||||||
import { VideoRecordingOverlay } from './VideoRecordingOverlay';
|
import { VideoRecordingOverlay } from './VideoRecordingOverlay';
|
||||||
import { AudioRecordingOverlay } from './AudioRecordingOverlay';
|
import { AudioRecordingOverlay } from './AudioRecordingOverlay';
|
||||||
import { ReviewSheet } from './ReviewSheet';
|
import { ReviewSheet } from './ReviewSheet';
|
||||||
@@ -48,6 +58,12 @@ interface ComposeDockProps {
|
|||||||
networkId: string;
|
networkId: string;
|
||||||
targetPath: ParticlePath;
|
targetPath: ParticlePath;
|
||||||
silentPresence?: boolean;
|
silentPresence?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to show the task compose button. Disabled in the new-stream flow,
|
||||||
|
* where a stream's first particle must be text or media (a task can't open a
|
||||||
|
* stream — it's added once the stream exists).
|
||||||
|
*/
|
||||||
|
allowTask?: boolean;
|
||||||
submitMedia?: (params: SubmitMediaParams) => Promise<void>;
|
submitMedia?: (params: SubmitMediaParams) => Promise<void>;
|
||||||
submitText?: (content: string) => Promise<void>;
|
submitText?: (content: string) => Promise<void>;
|
||||||
/**
|
/**
|
||||||
@@ -63,6 +79,7 @@ export function ComposeDock({
|
|||||||
networkId,
|
networkId,
|
||||||
targetPath,
|
targetPath,
|
||||||
silentPresence = false,
|
silentPresence = false,
|
||||||
|
allowTask = true,
|
||||||
submitMedia,
|
submitMedia,
|
||||||
submitText: submitTextOverride,
|
submitText: submitTextOverride,
|
||||||
onParticleCreated,
|
onParticleCreated,
|
||||||
@@ -72,6 +89,7 @@ export function ComposeDock({
|
|||||||
const [mode, setMode] = useState<RecordingMode>('video');
|
const [mode, setMode] = useState<RecordingMode>('video');
|
||||||
const [ui, setUi] = useState<ComposeUiState>({ kind: 'idle' });
|
const [ui, setUi] = useState<ComposeUiState>({ kind: 'idle' });
|
||||||
const [textOpen, setTextOpen] = useState(false);
|
const [textOpen, setTextOpen] = useState(false);
|
||||||
|
const [taskOpen, setTaskOpen] = useState(false);
|
||||||
|
|
||||||
const [camPerm, requestCamPerm] = useCameraPermissions();
|
const [camPerm, requestCamPerm] = useCameraPermissions();
|
||||||
const [micPerm, requestMicPerm] = useMicrophonePermissions();
|
const [micPerm, requestMicPerm] = useMicrophonePermissions();
|
||||||
@@ -79,13 +97,13 @@ export function ComposeDock({
|
|||||||
// Tell StreamView to fully unmount its expo-video player while we record.
|
// Tell StreamView to fully unmount its expo-video player while we record.
|
||||||
// That player otherwise holds the iOS AVAudioSession and crashes the camera.
|
// That player otherwise holds the iOS AVAudioSession and crashes the camera.
|
||||||
const setComposing = usePlaybackPauseStore((s) => s.setComposing);
|
const setComposing = usePlaybackPauseStore((s) => s.setComposing);
|
||||||
const isComposing = ui.kind !== 'idle' || textOpen;
|
const isComposing = ui.kind !== 'idle' || textOpen || taskOpen;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setComposing(isComposing);
|
setComposing(isComposing);
|
||||||
return () => setComposing(false);
|
return () => setComposing(false);
|
||||||
}, [isComposing, setComposing]);
|
}, [isComposing, setComposing]);
|
||||||
|
|
||||||
useComposingBroadcast({ ui, textOpen, silent: silentPresence });
|
useComposingBroadcast({ ui, textOpen, taskOpen, silent: silentPresence });
|
||||||
|
|
||||||
const ensurePermissions = useCallback(
|
const ensurePermissions = useCallback(
|
||||||
async (forVideo: boolean): Promise<boolean> => {
|
async (forVideo: boolean): Promise<boolean> => {
|
||||||
@@ -187,6 +205,20 @@ export function ComposeDock({
|
|||||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const submitTask = useEvent(
|
||||||
|
async ({ title, notes }: { title: string; notes?: string }) => {
|
||||||
|
if (!userId) throw new Error('Not signed in.');
|
||||||
|
const particleId = await createTaskParticle({
|
||||||
|
targetPath,
|
||||||
|
title,
|
||||||
|
notes,
|
||||||
|
createdByHumanId: userId,
|
||||||
|
});
|
||||||
|
onParticleCreated?.(particleId);
|
||||||
|
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const dockHidden =
|
const dockHidden =
|
||||||
ui.kind === 'review' || ui.kind === 'uploading' || ui.kind === 'recording';
|
ui.kind === 'review' || ui.kind === 'uploading' || ui.kind === 'recording';
|
||||||
|
|
||||||
@@ -196,27 +228,31 @@ export function ComposeDock({
|
|||||||
<View pointerEvents="box-none" className="absolute inset-x-0 bottom-0">
|
<View pointerEvents="box-none" className="absolute inset-x-0 bottom-0">
|
||||||
<View
|
<View
|
||||||
pointerEvents="box-none"
|
pointerEvents="box-none"
|
||||||
className="flex-row items-center justify-between px-8 pb-10"
|
className="flex-row items-center px-8 pb-10"
|
||||||
>
|
>
|
||||||
<Pressable
|
{/* Left and right clusters flex equally so the record button stays
|
||||||
onPress={() =>
|
centered regardless of how many side controls are present. */}
|
||||||
setMode((m) => (m === 'video' ? 'audio' : 'video'))
|
<View className="flex-1 flex-row items-center">
|
||||||
}
|
<Pressable
|
||||||
disabled={ui.kind !== 'idle'}
|
onPress={() =>
|
||||||
accessibilityLabel={`Switch to ${
|
setMode((m) => (m === 'video' ? 'audio' : 'video'))
|
||||||
mode === 'video' ? 'audio' : 'video'
|
}
|
||||||
} mode`}
|
disabled={ui.kind !== 'idle'}
|
||||||
className={cn(
|
accessibilityLabel={`Switch to ${
|
||||||
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
mode === 'video' ? 'audio' : 'video'
|
||||||
ui.kind !== 'idle' && 'opacity-40',
|
} mode`}
|
||||||
)}
|
className={cn(
|
||||||
>
|
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||||
{mode === 'video' ? (
|
ui.kind !== 'idle' && 'opacity-40',
|
||||||
<VideoIcon color="white" size={20} strokeWidth={1.6} />
|
)}
|
||||||
) : (
|
>
|
||||||
<Mic color="white" size={20} strokeWidth={1.6} />
|
{mode === 'video' ? (
|
||||||
)}
|
<VideoIcon color="white" size={20} strokeWidth={1.6} />
|
||||||
</Pressable>
|
) : (
|
||||||
|
<Mic color="white" size={20} strokeWidth={1.6} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className="items-center">
|
<View className="items-center">
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -230,17 +266,33 @@ export function ComposeDock({
|
|||||||
<Text className="text-white/60 mt-2 text-xs">Tap to record</Text>
|
<Text className="text-white/60 mt-2 text-xs">Tap to record</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Pressable
|
<View className="flex-1 flex-row items-center justify-end gap-3">
|
||||||
onPress={() => setTextOpen(true)}
|
{allowTask ? (
|
||||||
disabled={ui.kind !== 'idle'}
|
<Pressable
|
||||||
accessibilityLabel="Compose text"
|
onPress={() => setTaskOpen(true)}
|
||||||
className={cn(
|
disabled={ui.kind !== 'idle'}
|
||||||
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
accessibilityLabel="Create task"
|
||||||
ui.kind !== 'idle' && 'opacity-40',
|
className={cn(
|
||||||
)}
|
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||||
>
|
ui.kind !== 'idle' && 'opacity-40',
|
||||||
<TypeIcon color="white" size={20} strokeWidth={1.6} />
|
)}
|
||||||
</Pressable>
|
>
|
||||||
|
<ListTodo color="white" size={20} strokeWidth={1.6} />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setTextOpen(true)}
|
||||||
|
disabled={ui.kind !== 'idle'}
|
||||||
|
accessibilityLabel="Compose text"
|
||||||
|
className={cn(
|
||||||
|
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||||
|
ui.kind !== 'idle' && 'opacity-40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TypeIcon color="white" size={20} strokeWidth={1.6} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -277,6 +329,12 @@ export function ComposeDock({
|
|||||||
onClose={() => setTextOpen(false)}
|
onClose={() => setTextOpen(false)}
|
||||||
onSubmit={submitText}
|
onSubmit={submitText}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TaskComposeSheet
|
||||||
|
open={taskOpen}
|
||||||
|
onClose={() => setTaskOpen(false)}
|
||||||
|
onSubmit={submitTask}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -284,17 +342,23 @@ export function ComposeDock({
|
|||||||
function useComposingBroadcast({
|
function useComposingBroadcast({
|
||||||
ui,
|
ui,
|
||||||
textOpen,
|
textOpen,
|
||||||
|
taskOpen,
|
||||||
silent,
|
silent,
|
||||||
}: {
|
}: {
|
||||||
ui: ComposeUiState;
|
ui: ComposeUiState;
|
||||||
textOpen: boolean;
|
textOpen: boolean;
|
||||||
|
taskOpen: boolean;
|
||||||
silent: boolean;
|
silent: boolean;
|
||||||
}) {
|
}) {
|
||||||
// null when the dock is rendered outside a stream (no presence provider).
|
// null when the dock is rendered outside a stream (no presence provider).
|
||||||
const broadcast = useStreamComposingBroadcastOptional();
|
const broadcast = useStreamComposingBroadcastOptional();
|
||||||
|
|
||||||
const mode: ComposingMode | null =
|
const mode: ComposingMode | null =
|
||||||
ui.kind === 'recording' ? 'recording' : textOpen ? 'typing' : null;
|
ui.kind === 'recording'
|
||||||
|
? 'recording'
|
||||||
|
: textOpen || taskOpen
|
||||||
|
? 'typing'
|
||||||
|
: null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (silent || !broadcast) return;
|
if (silent || !broadcast) return;
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Pressable, Text, TextInput, View } from 'react-native';
|
||||||
|
import { toast } from 'sonner-native';
|
||||||
|
import { BottomSheet } from '@/components/BottomSheet';
|
||||||
|
import { toUserMessage } from '@/lib/errors';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TaskComposeSheetProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Create the task. Must throw on failure so the sheet keeps the draft. */
|
||||||
|
onSubmit: (input: { title: string; notes?: string }) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick task creator. Mirrors desktop's task-compose-step but pared to the
|
||||||
|
* essentials — title (required) plus optional notes. Checklist and assignee
|
||||||
|
* are added inline in the task card once it exists.
|
||||||
|
*/
|
||||||
|
export function TaskComposeSheet({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: TaskComposeSheetProps) {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [prevOpen, setPrevOpen] = useState(open);
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
|
if (open) {
|
||||||
|
setTitle('');
|
||||||
|
setNotes('');
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedTitle = title.trim();
|
||||||
|
const canSend = trimmedTitle.length > 0 && !submitting;
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!canSend) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSubmit({
|
||||||
|
title: trimmedTitle,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheet open={open} onClose={onClose} avoidKeyboard maxHeight="70%">
|
||||||
|
<View className="px-5 pb-4">
|
||||||
|
<Text className="text-white text-lg font-semibold">New task</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={title}
|
||||||
|
onChangeText={setTitle}
|
||||||
|
placeholder="Task title"
|
||||||
|
placeholderTextColor="rgba(255,255,255,0.4)"
|
||||||
|
autoFocus
|
||||||
|
editable={!submitting}
|
||||||
|
className="text-white text-base bg-white/10 rounded-xl px-4 py-3 mt-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
placeholder="Notes (optional)"
|
||||||
|
placeholderTextColor="rgba(255,255,255,0.4)"
|
||||||
|
multiline
|
||||||
|
editable={!submitting}
|
||||||
|
className="text-white text-base bg-white/10 rounded-xl px-4 py-3 mt-3 min-h-20"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={!canSend}
|
||||||
|
className={cn(
|
||||||
|
'mt-4 rounded-xl py-3 items-center',
|
||||||
|
canSend ? 'bg-white' : 'bg-white/20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={cn(
|
||||||
|
'text-base font-semibold',
|
||||||
|
canSend ? 'text-black' : 'text-white/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{submitting ? 'Creating…' : 'Create task'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</BottomSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { Alert, Dimensions, Pressable, Text, View } from 'react-native';
|
import { Alert, Dimensions, Pressable, Text, View } from 'react-native';
|
||||||
import type { Human } from '@/api/types';
|
import type { Human } from '@/api/types';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import {
|
import {
|
||||||
@@ -215,7 +216,6 @@ function Tile({
|
|||||||
const identity = tile.participant.identity;
|
const identity = tile.participant.identity;
|
||||||
const display = resolveHumanDisplay(identity, humans);
|
const display = resolveHumanDisplay(identity, humans);
|
||||||
const name = tile.participant.name || display.displayName;
|
const name = tile.participant.name || display.displayName;
|
||||||
const initials = display.initials;
|
|
||||||
const isSpeaking = tile.participant.isSpeaking;
|
const isSpeaking = tile.participant.isSpeaking;
|
||||||
const muted =
|
const muted =
|
||||||
tile.participant.getTrackPublication(Track.Source.Microphone)?.isMuted ??
|
tile.participant.getTrackPublication(Track.Source.Microphone)?.isMuted ??
|
||||||
@@ -234,9 +234,7 @@ function Tile({
|
|||||||
<VideoTrack trackRef={tile} style={{ flex: 1 }} objectFit="cover" />
|
<VideoTrack trackRef={tile} style={{ flex: 1 }} objectFit="cover" />
|
||||||
) : (
|
) : (
|
||||||
<View className="flex-1 items-center justify-center">
|
<View className="flex-1 items-center justify-center">
|
||||||
<View className="h-16 w-16 items-center justify-center rounded-full bg-neutral-700">
|
<Avatar humanId={identity} humans={humans} size="lg" />
|
||||||
<Text className="text-white text-xl font-semibold">{initials}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View className="absolute left-2 bottom-2 flex-row items-center gap-1 rounded-full bg-black/60 px-2 py-1">
|
<View className="absolute left-2 bottom-2 flex-row items-center gap-1 rounded-full bg-black/60 px-2 py-1">
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Pressable, Text, TextInput, View } from 'react-native';
|
||||||
|
import { toast } from 'sonner-native';
|
||||||
|
import { BottomSheet } from '@/components/BottomSheet';
|
||||||
|
import { useAddMembers } from '@/hooks/use-member-management';
|
||||||
|
import { toUserMessage } from '@/lib/errors';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface AddMembersSheetProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
networkId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invite people to a network by email. Mirrors desktop's add-members-dialog —
|
||||||
|
* accepts one email at a time (comma/space/enter to commit), shows chips, and
|
||||||
|
* submits the batch via `addMembers`.
|
||||||
|
*/
|
||||||
|
export function AddMembersSheet({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
networkId,
|
||||||
|
}: AddMembersSheetProps) {
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const [emails, setEmails] = useState<string[]>([]);
|
||||||
|
const addMembers = useAddMembers(networkId);
|
||||||
|
|
||||||
|
const [prevOpen, setPrevOpen] = useState(open);
|
||||||
|
if (open !== prevOpen) {
|
||||||
|
setPrevOpen(open);
|
||||||
|
if (open) {
|
||||||
|
setDraft('');
|
||||||
|
setEmails([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitDraft = (): string[] => {
|
||||||
|
const candidate = draft.trim().toLowerCase().replace(/,$/, '');
|
||||||
|
if (!candidate) return emails;
|
||||||
|
if (!EMAIL_RE.test(candidate)) {
|
||||||
|
toast.error('Enter a valid email address');
|
||||||
|
return emails;
|
||||||
|
}
|
||||||
|
if (emails.includes(candidate)) {
|
||||||
|
setDraft('');
|
||||||
|
return emails;
|
||||||
|
}
|
||||||
|
const next = [...emails, candidate];
|
||||||
|
setEmails(next);
|
||||||
|
setDraft('');
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEmail = (email: string) =>
|
||||||
|
setEmails((prev) => prev.filter((e) => e !== email));
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const finalEmails = commitDraft();
|
||||||
|
if (finalEmails.length === 0) return;
|
||||||
|
try {
|
||||||
|
await addMembers.mutateAsync(finalEmails);
|
||||||
|
toast.success(
|
||||||
|
finalEmails.length === 1
|
||||||
|
? 'Invitation sent'
|
||||||
|
: `${finalEmails.length} invitations sent`,
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
!addMembers.isPending &&
|
||||||
|
(emails.length > 0 || EMAIL_RE.test(draft.trim().toLowerCase()));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomSheet open={open} onClose={onClose} avoidKeyboard maxHeight="60%">
|
||||||
|
<View className="px-5 pb-4">
|
||||||
|
<Text className="text-white text-lg font-semibold">Add members</Text>
|
||||||
|
<Text className="text-white/50 text-sm mt-1">
|
||||||
|
Existing users join right away; others get an email invite.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{emails.length > 0 ? (
|
||||||
|
<View className="flex-row flex-wrap gap-2 mt-4">
|
||||||
|
{emails.map((email) => (
|
||||||
|
<Pressable
|
||||||
|
key={email}
|
||||||
|
onPress={() => removeEmail(email)}
|
||||||
|
className="flex-row items-center bg-white/10 rounded-full pl-3 pr-2 py-1"
|
||||||
|
>
|
||||||
|
<Text className="text-white text-sm">{email}</Text>
|
||||||
|
<Text className="text-white/50 text-base ml-1">×</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={draft}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
if (text.endsWith(',') || text.endsWith(' ')) {
|
||||||
|
setDraft(text);
|
||||||
|
commitDraft();
|
||||||
|
} else {
|
||||||
|
setDraft(text);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="name@example.com"
|
||||||
|
placeholderTextColor="rgba(255,255,255,0.4)"
|
||||||
|
autoFocus
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="email-address"
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={() => commitDraft()}
|
||||||
|
editable={!addMembers.isPending}
|
||||||
|
className="text-white text-base bg-white/10 rounded-xl px-4 py-3 mt-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className={cn(
|
||||||
|
'mt-4 rounded-xl py-3 items-center',
|
||||||
|
canSubmit ? 'bg-white' : 'bg-white/20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={cn(
|
||||||
|
'text-base font-semibold',
|
||||||
|
canSubmit ? 'text-black' : 'text-white/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{addMembers.isPending ? 'Sending…' : 'Send invites'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</BottomSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Linking, Pressable, Text, View } from 'react-native';
|
||||||
|
import { ExternalLink } from 'lucide-react-native';
|
||||||
|
import { toast } from 'sonner-native';
|
||||||
|
import type { BillingCadence, BillingStatus } from '@/api/types';
|
||||||
|
import {
|
||||||
|
useCreateCheckoutSession,
|
||||||
|
useCreatePortalSession,
|
||||||
|
useNetworkBilling,
|
||||||
|
useNetworkUsage,
|
||||||
|
} from '@/hooks/use-billing';
|
||||||
|
import { useIsNetworkAdmin } from '@/hooks/use-networks';
|
||||||
|
import { toUserMessage } from '@/lib/errors';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function formatCents(cents: number): string {
|
||||||
|
if (cents % 100 === 0) return `$${cents / 100}`;
|
||||||
|
return `$${(cents / 100).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center gap-3 px-1 py-2">
|
||||||
|
<Text className="text-muted-foreground text-sm">{label}</Text>
|
||||||
|
<View className="flex-1" />
|
||||||
|
<View>{value}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan + usage summary for every member, plus admin-only upgrade/manage
|
||||||
|
* controls. Mirrors desktop's BillingSection — `/usage` powers the
|
||||||
|
* everyone-visible summary; `/billing` (admin-gated) drives the controls.
|
||||||
|
* Stripe checkout/portal URLs are opened in the system browser.
|
||||||
|
*/
|
||||||
|
export function BillingSection({ networkId }: { networkId: string }) {
|
||||||
|
const isAdmin = useIsNetworkAdmin(networkId);
|
||||||
|
const { data: usage } = useNetworkUsage(networkId);
|
||||||
|
|
||||||
|
const isPro = usage?.plan === 'pro';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="px-3">
|
||||||
|
<Text className="text-foreground text-base font-semibold pt-2 pb-2">
|
||||||
|
Plan & 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,
|
SafeAreaProvider,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
} from 'react-native-safe-area-context';
|
} from 'react-native-safe-area-context';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||||
@@ -26,7 +27,12 @@ interface DrawerProps {
|
|||||||
onNavigateSettings: () => void;
|
onNavigateSettings: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
|
export function Drawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onNavigateAccount,
|
||||||
|
onNavigateSettings,
|
||||||
|
}: DrawerProps) {
|
||||||
// Lazy-init so each Animated.Value is created once; the setters are never
|
// Lazy-init so each Animated.Value is created once; the setters are never
|
||||||
// called — the values are mutated internally by the native driver.
|
// called — the values are mutated internally by the native driver.
|
||||||
const [translateX] = useState(() => new Animated.Value(-DRAWER_WIDTH));
|
const [translateX] = useState(() => new Animated.Value(-DRAWER_WIDTH));
|
||||||
@@ -53,8 +59,6 @@ export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
|
|||||||
const signOut = useAuthStore((s) => s.signOut);
|
const signOut = useAuthStore((s) => s.signOut);
|
||||||
const isSigningOut = useAuthStore((s) => s.isSigningOut);
|
const isSigningOut = useAuthStore((s) => s.isSigningOut);
|
||||||
|
|
||||||
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
visible={open}
|
visible={open}
|
||||||
@@ -85,11 +89,11 @@ export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
|
|||||||
>
|
>
|
||||||
<SafeAreaView edges={['top', 'bottom', 'left']} className="flex-1">
|
<SafeAreaView edges={['top', 'bottom', 'left']} className="flex-1">
|
||||||
<View className="border-sidebar-border border-b px-5 py-5 flex-row items-center gap-3">
|
<View className="border-sidebar-border border-b px-5 py-5 flex-row items-center gap-3">
|
||||||
<View className="bg-sidebar-accent h-10 w-10 items-center justify-center rounded-full">
|
<Avatar
|
||||||
<Text className="text-sidebar-accent-foreground text-sm font-semibold">
|
humanId={user?.id}
|
||||||
{initials}
|
humans={user ? [user] : undefined}
|
||||||
</Text>
|
size="md"
|
||||||
</View>
|
/>
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text
|
<Text
|
||||||
className="text-sidebar-foreground text-base font-medium"
|
className="text-sidebar-foreground text-base font-medium"
|
||||||
@@ -114,6 +118,13 @@ export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
|
|||||||
onNavigateAccount();
|
onNavigateAccount();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<DrawerRow
|
||||||
|
label="Settings"
|
||||||
|
onPress={() => {
|
||||||
|
onClose();
|
||||||
|
onNavigateSettings();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="border-sidebar-border border-t px-2 py-2">
|
<View className="border-sidebar-border border-t px-2 py-2">
|
||||||
|
|||||||
@@ -8,22 +8,28 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import type { Network } from '@/api/types';
|
import { Plus } from 'lucide-react-native';
|
||||||
|
import { toast } from 'sonner-native';
|
||||||
|
import type { Invitation, Network } from '@/api/types';
|
||||||
import { useNetworks } from '@/hooks/use-networks';
|
import { useNetworks } from '@/hooks/use-networks';
|
||||||
|
import { useAcceptInvitation, useMyInvitations } from '@/hooks/use-invitations';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { toUserMessage } from '@/lib/errors';
|
import { toUserMessage } from '@/lib/errors';
|
||||||
import type { RootStackScreenProps } from '@/navigation/types';
|
import type { RootStackScreenProps } from '@/navigation/types';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { FlowyLogo } from '@/components/FlowyLogo';
|
import { FlowyLogo } from '@/components/FlowyLogo';
|
||||||
import { ListSeparator } from '@/components/ListSeparator';
|
import { ListSeparator } from '@/components/ListSeparator';
|
||||||
import { Drawer } from './Drawer';
|
import { Drawer } from './Drawer';
|
||||||
|
import { CreateNetworkSheet } from './CreateNetworkSheet';
|
||||||
|
|
||||||
export function NetworkListScreen({
|
export function NetworkListScreen({
|
||||||
navigation,
|
navigation,
|
||||||
}: RootStackScreenProps<'NetworkList'>) {
|
}: RootStackScreenProps<'NetworkList'>) {
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const { data, isLoading, refetch, error } = useNetworks();
|
const { data, isLoading, refetch, error } = useNetworks();
|
||||||
|
const { data: invitations, refetch: refetchInvitations } = useMyInvitations();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? '??';
|
|
||||||
|
|
||||||
// Local refreshing state — driving RefreshControl from react-query's
|
// Local refreshing state — driving RefreshControl from react-query's
|
||||||
// isRefetching can leave the native spinner visually stuck after the
|
// isRefetching can leave the native spinner visually stuck after the
|
||||||
@@ -32,11 +38,13 @@ export function NetworkListScreen({
|
|||||||
const onRefresh = useCallback(async () => {
|
const onRefresh = useCallback(async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
await refetch();
|
await Promise.all([refetch(), refetchInvitations()]);
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
}, [refetch]);
|
}, [refetch, refetchInvitations]);
|
||||||
|
|
||||||
|
const pendingInvitations = invitations ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
@@ -44,14 +52,21 @@ export function NetworkListScreen({
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setDrawerOpen(true)}
|
onPress={() => setDrawerOpen(true)}
|
||||||
accessibilityLabel="Open menu"
|
accessibilityLabel="Open menu"
|
||||||
className="bg-muted h-9 w-9 items-center justify-center rounded-full"
|
|
||||||
>
|
>
|
||||||
<Text className="text-muted-foreground text-xs font-semibold">
|
<Avatar
|
||||||
{initials}
|
humanId={user?.id}
|
||||||
</Text>
|
humans={user ? [user] : undefined}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<FlowyLogo />
|
<FlowyLogo />
|
||||||
<View className="w-9" />
|
<Pressable
|
||||||
|
onPress={() => setCreateOpen(true)}
|
||||||
|
accessibilityLabel="Create network"
|
||||||
|
className="bg-muted h-9 w-9 items-center justify-center rounded-full"
|
||||||
|
>
|
||||||
|
<Plus size={18} color="#fafafa" />
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -67,16 +82,21 @@ export function NetworkListScreen({
|
|||||||
<Text className="text-foreground font-medium">Retry</Text>
|
<Text className="text-foreground font-medium">Retry</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
) : !data || data.length === 0 ? (
|
) : (!data || data.length === 0) && pendingInvitations.length === 0 ? (
|
||||||
<EmptyState />
|
<EmptyState onCreate={() => setCreateOpen(true)} />
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={data}
|
data={data ?? []}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||||
}
|
}
|
||||||
ItemSeparatorComponent={ListSeparator}
|
ItemSeparatorComponent={ListSeparator}
|
||||||
|
ListHeaderComponent={
|
||||||
|
pendingInvitations.length > 0 ? (
|
||||||
|
<InvitationsSection invitations={pendingInvitations} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<NetworkCard
|
<NetworkCard
|
||||||
network={item}
|
network={item}
|
||||||
@@ -94,10 +114,65 @@ export function NetworkListScreen({
|
|||||||
onNavigateAccount={() => navigation.navigate('Account')}
|
onNavigateAccount={() => navigation.navigate('Account')}
|
||||||
onNavigateSettings={() => navigation.navigate('Settings')}
|
onNavigateSettings={() => navigation.navigate('Settings')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CreateNetworkSheet
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
onCreated={(networkId) =>
|
||||||
|
navigation.navigate('StreamList', { networkId })
|
||||||
|
}
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InvitationsSection({ invitations }: { invitations: Invitation[] }) {
|
||||||
|
return (
|
||||||
|
<View className="border-b border-border">
|
||||||
|
<Text className="text-muted-foreground text-xs uppercase tracking-wide px-4 pt-4 pb-1">
|
||||||
|
Invitations
|
||||||
|
</Text>
|
||||||
|
{invitations.map((invitation) => (
|
||||||
|
<InvitationCard key={invitation.network_id} invitation={invitation} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvitationCard({ invitation }: { invitation: Invitation }) {
|
||||||
|
const acceptInvitation = useAcceptInvitation();
|
||||||
|
|
||||||
|
const handleAccept = async () => {
|
||||||
|
try {
|
||||||
|
await acceptInvitation.mutateAsync(invitation.network_id);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center justify-between px-4 py-4">
|
||||||
|
<View className="flex-1 pr-3">
|
||||||
|
<Text className="text-foreground text-base font-semibold">
|
||||||
|
{invitation.network_name}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-sm">
|
||||||
|
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({
|
function NetworkCard({
|
||||||
network,
|
network,
|
||||||
onPress,
|
onPress,
|
||||||
@@ -124,15 +199,23 @@ function NetworkCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyState() {
|
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
||||||
return (
|
return (
|
||||||
<View className="flex-1 items-center justify-center px-6">
|
<View className="flex-1 items-center justify-center px-6">
|
||||||
<Text className="text-foreground text-lg font-medium text-center">
|
<Text className="text-foreground text-lg font-medium text-center">
|
||||||
You aren’t in any networks yet.
|
You aren’t in any networks yet.
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-muted-foreground mt-2 text-center">
|
<Text className="text-muted-foreground mt-2 text-center">
|
||||||
Ask a friend for an invite, or create one on desktop.
|
Create one to get started, or ask a friend for an invite.
|
||||||
</Text>
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
onPress={onCreate}
|
||||||
|
className="bg-primary rounded-full px-5 py-3 mt-6"
|
||||||
|
>
|
||||||
|
<Text className="text-primary-foreground font-semibold">
|
||||||
|
Create a network
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,68 @@
|
|||||||
import { Pressable, Text, View } from 'react-native';
|
import { useState } from 'react';
|
||||||
|
import { ActivityIndicator, Alert, Pressable, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import { Camera } from 'lucide-react-native';
|
||||||
|
import { toast } from 'sonner-native';
|
||||||
|
import { apiClient } from '@/api/client';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { toUserMessage } from '@/lib/errors';
|
||||||
import type { RootStackScreenProps } from '@/navigation/types';
|
import type { RootStackScreenProps } from '@/navigation/types';
|
||||||
|
|
||||||
export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
|
export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const refreshUser = useAuthStore((s) => s.refreshUser);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const pickAndUpload = async () => {
|
||||||
|
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (!permission.granted) {
|
||||||
|
toast.error('Photo library access is needed to set an avatar.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ['images'],
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [1, 1],
|
||||||
|
quality: 0.8,
|
||||||
|
});
|
||||||
|
if (result.canceled) return;
|
||||||
|
const asset = result.assets[0];
|
||||||
|
if (!asset) return;
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await apiClient.uploadAvatar(asset.uri, asset.mimeType ?? 'image/jpeg');
|
||||||
|
await refreshUser();
|
||||||
|
toast.success('Avatar updated');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAvatar = () => {
|
||||||
|
Alert.alert('Remove avatar?', 'Your initials will be shown instead.', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Remove',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await apiClient.deleteAvatar();
|
||||||
|
await refreshUser();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
@@ -18,7 +76,38 @@ export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
|
|||||||
<View className="w-8" />
|
<View className="w-8" />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="px-6 py-6 gap-4">
|
<View className="items-center px-6 py-8">
|
||||||
|
<Pressable
|
||||||
|
onPress={pickAndUpload}
|
||||||
|
disabled={busy}
|
||||||
|
accessibilityLabel="Change profile picture"
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<View className="h-24 w-24 items-center justify-center rounded-full bg-muted">
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Avatar
|
||||||
|
humanId={user?.id}
|
||||||
|
humans={user ? [user] : undefined}
|
||||||
|
size="xl"
|
||||||
|
className="bg-muted"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View className="absolute bottom-0 right-0 h-7 w-7 items-center justify-center rounded-full bg-primary border-2 border-background">
|
||||||
|
<Camera size={13} color="#000000" />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{user?.avatar_object_id ? (
|
||||||
|
<Pressable onPress={removeAvatar} disabled={busy} className="mt-3">
|
||||||
|
<Text className="text-destructive text-sm">Remove photo</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="px-6 gap-4">
|
||||||
<Field label="Email" value={user?.email ?? '—'} />
|
<Field label="Email" value={user?.email ?? '—'} />
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|||||||
@@ -1,10 +1,47 @@
|
|||||||
import { Pressable, Text, View } from 'react-native';
|
import { useState } from 'react';
|
||||||
|
import { Pressable, Switch, Text, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import { apiClient } from '@/api/client';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
import { logError, toUserMessage } from '@/lib/errors';
|
||||||
|
import { toast } from 'sonner-native';
|
||||||
import type { RootStackScreenProps } from '@/navigation/types';
|
import type { RootStackScreenProps } from '@/navigation/types';
|
||||||
|
|
||||||
export function SettingsScreen({
|
export function SettingsScreen({
|
||||||
navigation,
|
navigation,
|
||||||
}: RootStackScreenProps<'Settings'>) {
|
}: RootStackScreenProps<'Settings'>) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const signOut = useAuthStore((s) => s.signOut);
|
||||||
|
const isSigningOut = useAuthStore((s) => s.isSigningOut);
|
||||||
|
const [emailNotifications, setEmailNotifications] = useState(
|
||||||
|
user?.email_notifications_enabled ?? true,
|
||||||
|
);
|
||||||
|
const version = Constants.expoConfig?.version ?? '—';
|
||||||
|
|
||||||
|
// Optimistic toggle — flip the local + auth-store state immediately, roll
|
||||||
|
// back on failure. Mirrors desktop's settings-page handler.
|
||||||
|
const handleToggleEmailNotifications = async (checked: boolean) => {
|
||||||
|
setEmailNotifications(checked);
|
||||||
|
useAuthStore.setState((state) => ({
|
||||||
|
user: state.user
|
||||||
|
? { ...state.user, email_notifications_enabled: checked }
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
await apiClient.updateSettings({ email_notifications_enabled: checked });
|
||||||
|
} catch (err) {
|
||||||
|
setEmailNotifications(!checked);
|
||||||
|
useAuthStore.setState((state) => ({
|
||||||
|
user: state.user
|
||||||
|
? { ...state.user, email_notifications_enabled: !checked }
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
logError(err, { scope: 'settings.emailNotifications' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
||||||
@@ -17,11 +54,55 @@ export function SettingsScreen({
|
|||||||
<View className="w-8" />
|
<View className="w-8" />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex-1 items-center justify-center px-6">
|
<View className="px-4 py-4">
|
||||||
<Text className="text-muted-foreground text-center">
|
<SettingsGroup title="Notifications">
|
||||||
Theme, notifications, and account preferences land here later.
|
<View className="flex-row items-center justify-between px-4 py-3">
|
||||||
</Text>
|
<Text className="text-foreground text-base">
|
||||||
|
Email notifications
|
||||||
|
</Text>
|
||||||
|
<Switch
|
||||||
|
value={emailNotifications}
|
||||||
|
onValueChange={handleToggleEmailNotifications}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup title="About">
|
||||||
|
<View className="flex-row items-center justify-between px-4 py-3">
|
||||||
|
<Text className="text-foreground text-base">Version</Text>
|
||||||
|
<Text className="text-muted-foreground text-base">{version}</Text>
|
||||||
|
</View>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<View className="mt-4">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void signOut()}
|
||||||
|
disabled={isSigningOut}
|
||||||
|
className="px-4 py-3 active:bg-accent rounded-xl"
|
||||||
|
>
|
||||||
|
<Text className="text-destructive text-base font-medium">
|
||||||
|
{isSigningOut ? 'Signing out…' : 'Sign out'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SettingsGroup({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="mb-2">
|
||||||
|
<Text className="text-muted-foreground text-xs uppercase tracking-wide px-4 pt-4 pb-1">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<View className="bg-muted/40 rounded-xl overflow-hidden">{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +1,10 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Text, View } from 'react-native';
|
import { Text, View } from 'react-native';
|
||||||
import {
|
import { HelpCircle } from 'lucide-react-native';
|
||||||
FileIcon,
|
|
||||||
HelpCircle,
|
|
||||||
ScrollText,
|
|
||||||
BookOpen,
|
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react-native';
|
|
||||||
import type { Particle } from '@/api/types';
|
import type { Particle } from '@/api/types';
|
||||||
import { useNetwork } from '@/hooks/use-networks';
|
import { useNetwork } from '@/hooks/use-networks';
|
||||||
import { resolveHumanDisplay } from '@/lib/humans';
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
|
|
||||||
const TYPE_META: Record<string, { icon: LucideIcon; label: string }> = {
|
|
||||||
quest: { icon: ScrollText, label: 'Quest' },
|
|
||||||
paper: { icon: BookOpen, label: 'Paper' },
|
|
||||||
file: { icon: FileIcon, label: 'File' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const PLACEHOLDER_DURATION_MS = 5000;
|
const PLACEHOLDER_DURATION_MS = 5000;
|
||||||
|
|
||||||
interface FallbackParticleViewProps {
|
interface FallbackParticleViewProps {
|
||||||
@@ -26,6 +14,10 @@ interface FallbackParticleViewProps {
|
|||||||
onEnded: () => void;
|
onEnded: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Catch-all for particle types this client version doesn't render with a
|
||||||
|
// dedicated view (e.g. a folder slipping into a stream, or a future type a
|
||||||
|
// newer client wrote). Known content types — media, text, task, paper, file —
|
||||||
|
// each have their own view in StreamView's switch.
|
||||||
export function FallbackParticleView({
|
export function FallbackParticleView({
|
||||||
particle,
|
particle,
|
||||||
networkId,
|
networkId,
|
||||||
@@ -37,25 +29,8 @@ export function FallbackParticleView({
|
|||||||
particle.created_by_human_id,
|
particle.created_by_human_id,
|
||||||
network?.humans,
|
network?.humans,
|
||||||
);
|
);
|
||||||
const meta = TYPE_META[particle.type] ?? {
|
const Icon = HelpCircle;
|
||||||
icon: HelpCircle,
|
const title = particle.type === 'folder' ? particle.properties.name : null;
|
||||||
label: particle.type,
|
|
||||||
};
|
|
||||||
const Icon = meta.icon;
|
|
||||||
const title = (() => {
|
|
||||||
switch (particle.type) {
|
|
||||||
case 'quest':
|
|
||||||
return particle.properties.title;
|
|
||||||
case 'paper':
|
|
||||||
return particle.properties.title;
|
|
||||||
case 'file':
|
|
||||||
return particle.properties.filename;
|
|
||||||
case 'folder':
|
|
||||||
return particle.properties.name;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (paused) return;
|
if (paused) return;
|
||||||
@@ -70,7 +45,7 @@ export function FallbackParticleView({
|
|||||||
<Icon color="rgba(255,255,255,0.7)" size={22} strokeWidth={1.5} />
|
<Icon color="rgba(255,255,255,0.7)" size={22} strokeWidth={1.5} />
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text className="text-white text-base font-semibold">
|
<Text className="text-white text-base font-semibold">
|
||||||
{meta.label}
|
{particle.type}
|
||||||
</Text>
|
</Text>
|
||||||
{title ? (
|
{title ? (
|
||||||
<Text className="text-white/70 text-sm" numberOfLines={2}>
|
<Text className="text-white/70 text-sm" numberOfLines={2}>
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Linking, Pressable, Text, View } from 'react-native';
|
||||||
|
import { Download, FileIcon } from 'lucide-react-native';
|
||||||
|
import { toast } from 'sonner-native';
|
||||||
|
import type { Particle } from '@/api/types';
|
||||||
|
import { apiClient } from '@/api/client';
|
||||||
|
import { toUserMessage } from '@/lib/errors';
|
||||||
|
|
||||||
|
type FileParticle = Extract<Particle, { type: 'file' }>;
|
||||||
|
|
||||||
|
interface FileParticleViewProps {
|
||||||
|
particle: FileParticle;
|
||||||
|
paused: boolean;
|
||||||
|
onEnded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files don't auto-play; give the reader a beat to act before advancing.
|
||||||
|
const DWELL_DURATION_MS = 8000;
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
const kb = bytes / 1024;
|
||||||
|
if (kb < 1024) return `${kb.toFixed(0)} KB`;
|
||||||
|
const mb = kb / 1024;
|
||||||
|
if (mb < 1024) return `${mb.toFixed(1)} MB`;
|
||||||
|
return `${(mb / 1024).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File particle: name, size, and a download action. Tapping resolves a signed
|
||||||
|
* URL and opens it (the OS handles the download / preview). Mirrors desktop's
|
||||||
|
* file attachment, minus in-app preview.
|
||||||
|
*/
|
||||||
|
export function FileParticleView({
|
||||||
|
particle,
|
||||||
|
paused,
|
||||||
|
onEnded,
|
||||||
|
}: FileParticleViewProps) {
|
||||||
|
const { filename, size_bytes, object_id } = particle.properties;
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const elapsedRef = useRef(0);
|
||||||
|
|
||||||
|
// Pause the dwell while a download is being resolved so the stream doesn't
|
||||||
|
// advance out from under the user mid-tap.
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused || downloading) return;
|
||||||
|
const start = Date.now();
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
elapsedRef.current += Date.now() - start;
|
||||||
|
if (elapsedRef.current >= DWELL_DURATION_MS) {
|
||||||
|
clearInterval(interval);
|
||||||
|
onEnded();
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [paused, downloading, onEnded, particle.id]);
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
const url = await apiClient.getParticleDownloadUrl(object_id);
|
||||||
|
const canOpen = await Linking.canOpenURL(url);
|
||||||
|
if (!canOpen) throw new Error('Could not open this file.');
|
||||||
|
await Linking.openURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(toUserMessage(err));
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center px-8">
|
||||||
|
<View className="bg-white/10 w-full max-w-sm rounded-2xl px-5 py-5">
|
||||||
|
<View className="flex-row items-center gap-3">
|
||||||
|
<FileIcon color="rgba(255,255,255,0.7)" size={26} strokeWidth={1.5} />
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
className="text-white text-base font-semibold"
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{filename}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-white/50 text-xs mt-0.5">
|
||||||
|
{formatBytes(size_bytes)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={handleDownload}
|
||||||
|
disabled={downloading}
|
||||||
|
className="mt-5 flex-row items-center justify-center gap-2 rounded-xl bg-white py-3"
|
||||||
|
>
|
||||||
|
<Download color="#000000" size={16} strokeWidth={2} />
|
||||||
|
<Text className="text-black text-base font-semibold">
|
||||||
|
{downloading ? 'Opening…' : 'Download'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { ScrollView, Text, View } from 'react-native';
|
||||||
|
import type { Particle } from '@/api/types';
|
||||||
|
import { MarkdownBody } from '@/components/MarkdownBody';
|
||||||
|
import { useStreamSafeArea } from './stream-safe-area';
|
||||||
|
|
||||||
|
type PaperParticle = Extract<Particle, { type: 'paper' }>;
|
||||||
|
|
||||||
|
interface PaperParticleViewProps {
|
||||||
|
particle: PaperParticle;
|
||||||
|
paused: boolean;
|
||||||
|
onEnded: () => void;
|
||||||
|
onProgress: (ratio: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Papers are longer-form documents, so they read at the text cadence but with a
|
||||||
|
// higher cap — the reader can still scroll at their own pace while the timer
|
||||||
|
// ticks toward auto-advance.
|
||||||
|
const CHARS_PER_MINUTE = 1000;
|
||||||
|
const MIN_DURATION_S = 4;
|
||||||
|
const MAX_DURATION_S = 30;
|
||||||
|
const TICK_MS = 100;
|
||||||
|
|
||||||
|
function computeReadDuration(text: string): number {
|
||||||
|
const base = (text.length / CHARS_PER_MINUTE) * 60;
|
||||||
|
return Math.min(Math.max(base, MIN_DURATION_S), MAX_DURATION_S);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only paper (document) view. Mirrors desktop's paper rendering: a title
|
||||||
|
* heading above markdown body, scrollable, with a length-based dwell timer.
|
||||||
|
*/
|
||||||
|
export function PaperParticleView({
|
||||||
|
particle,
|
||||||
|
paused,
|
||||||
|
onEnded,
|
||||||
|
onProgress,
|
||||||
|
}: PaperParticleViewProps) {
|
||||||
|
const { title, content } = particle.properties;
|
||||||
|
const safe = useStreamSafeArea();
|
||||||
|
const durationS = computeReadDuration(content);
|
||||||
|
const elapsedRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
elapsedRef.current = 0;
|
||||||
|
onProgress(0);
|
||||||
|
}, [particle.id, onProgress]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused) return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
elapsedRef.current += TICK_MS / 1000;
|
||||||
|
const ratio = Math.min(elapsedRef.current / durationS, 1);
|
||||||
|
onProgress(ratio);
|
||||||
|
if (ratio >= 1) {
|
||||||
|
clearInterval(interval);
|
||||||
|
onEnded();
|
||||||
|
}
|
||||||
|
}, TICK_MS);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [paused, durationS, onEnded, onProgress, particle.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex-1 items-center justify-center px-6"
|
||||||
|
style={{ paddingTop: safe.top + 16, paddingBottom: safe.bottom + 16 }}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
className="max-h-full w-full max-w-xl rounded-2xl bg-white/10"
|
||||||
|
contentContainerClassName="px-5 py-5"
|
||||||
|
showsVerticalScrollIndicator
|
||||||
|
indicatorStyle="white"
|
||||||
|
>
|
||||||
|
<Text className="text-white text-2xl font-semibold mb-3">{title}</Text>
|
||||||
|
<MarkdownBody content={content} />
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { Pressable, Text, View } from 'react-native';
|
|||||||
import { Plus } from 'lucide-react-native';
|
import { Plus } from 'lucide-react-native';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
|
import { REACTION_EMOJIS, type Reactions, type Human } from '@/api/types';
|
||||||
import { resolveHumanDisplay } from '@/lib/humans';
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
const EMOJI_SET = new Set<string>(REACTION_EMOJIS);
|
||||||
@@ -77,7 +77,6 @@ export function ReactionStack({
|
|||||||
{activeTextKeys.map((text) => {
|
{activeTextKeys.map((text) => {
|
||||||
const reactors = reactions?.[text] ?? [];
|
const reactors = reactions?.[text] ?? [];
|
||||||
const isMine = reactors.includes(currentHumanId);
|
const isMine = reactors.includes(currentHumanId);
|
||||||
const firstReactor = resolveHumanDisplay(reactors[0], humans);
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={text}
|
key={text}
|
||||||
@@ -93,11 +92,7 @@ export function ReactionStack({
|
|||||||
: null,
|
: null,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View className="bg-white/15 h-5 w-5 items-center justify-center rounded-full">
|
<Avatar humanId={reactors[0]} humans={humans} size="xs" />
|
||||||
<Text className="text-white text-[9px] font-semibold">
|
|
||||||
{firstReactor.initials}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text className="text-white/90 text-xs" numberOfLines={1}>
|
<Text className="text-white/90 text-xs" numberOfLines={1}>
|
||||||
{text}
|
{text}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import { Clock, Pause } from 'lucide-react-native';
|
||||||
|
|
||||||
|
interface StreamStatusPillsProps {
|
||||||
|
paused: boolean;
|
||||||
|
/** Null when no auto-exit is pending; ms remaining otherwise. */
|
||||||
|
exitRemainingMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact playback-state pills shown in the right chrome margin so they never
|
||||||
|
* overlay the particle canvas. "Paused" collapses to an icon-only badge; the
|
||||||
|
* exit countdown pairs a clock icon with tabular-number seconds so the digit
|
||||||
|
* tick doesn't reflow neighbors.
|
||||||
|
*/
|
||||||
|
export function StreamStatusPills({
|
||||||
|
paused,
|
||||||
|
exitRemainingMs,
|
||||||
|
}: StreamStatusPillsProps) {
|
||||||
|
if (!paused && exitRemainingMs === null) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center gap-1.5">
|
||||||
|
{paused ? (
|
||||||
|
<View
|
||||||
|
accessibilityLabel="Paused"
|
||||||
|
className="bg-white/15 h-6 w-6 items-center justify-center rounded-full"
|
||||||
|
>
|
||||||
|
<Pause color="white" size={11} fill="white" strokeWidth={0} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{exitRemainingMs !== null ? (
|
||||||
|
<View
|
||||||
|
accessibilityLabel={`Closing in ${Math.ceil(
|
||||||
|
exitRemainingMs / 1000,
|
||||||
|
)} seconds`}
|
||||||
|
className="bg-white/15 h-6 flex-row items-center gap-1 rounded-full px-2"
|
||||||
|
>
|
||||||
|
<Clock color="white" size={11} strokeWidth={2} />
|
||||||
|
<Text
|
||||||
|
className="text-white text-[11px] font-semibold"
|
||||||
|
style={{ fontVariant: ['tabular-nums'] }}
|
||||||
|
>
|
||||||
|
{Math.ceil(exitRemainingMs / 1000)}s
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -53,11 +53,15 @@ import {
|
|||||||
useStreamComposing,
|
useStreamComposing,
|
||||||
} from './stream-presence-context';
|
} from './stream-presence-context';
|
||||||
import { TextParticleView } from './TextParticleView';
|
import { TextParticleView } from './TextParticleView';
|
||||||
|
import { TaskParticleView } from './TaskParticleView';
|
||||||
|
import { PaperParticleView } from './PaperParticleView';
|
||||||
|
import { FileParticleView } from './FileParticleView';
|
||||||
import { MediaParticleView } from './MediaParticleView';
|
import { MediaParticleView } from './MediaParticleView';
|
||||||
import { DeletedParticleView } from './DeletedParticleView';
|
import { DeletedParticleView } from './DeletedParticleView';
|
||||||
import { FallbackParticleView } from './FallbackParticleView';
|
import { FallbackParticleView } from './FallbackParticleView';
|
||||||
import { useExitCountdown } from './use-exit-countdown';
|
import { useExitCountdown } from './use-exit-countdown';
|
||||||
import { StreamTopActions } from './StreamTopActions';
|
import { StreamTopActions } from './StreamTopActions';
|
||||||
|
import { StreamStatusPills } from './StreamStatusPills';
|
||||||
import { StreamActionsSheet, type StreamActionId } from './StreamActionsSheet';
|
import { StreamActionsSheet, type StreamActionId } from './StreamActionsSheet';
|
||||||
import { StreamMembersSheet } from './StreamMembersSheet';
|
import { StreamMembersSheet } from './StreamMembersSheet';
|
||||||
import { RenameStreamSheet } from './RenameStreamSheet';
|
import { RenameStreamSheet } from './RenameStreamSheet';
|
||||||
@@ -456,6 +460,37 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
contentFit={videoFit}
|
contentFit={videoFit}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case 'task':
|
||||||
|
return (
|
||||||
|
<TaskParticleView
|
||||||
|
key={particle.id}
|
||||||
|
particle={particle}
|
||||||
|
networkId={networkId}
|
||||||
|
streamId={streamParticle.id}
|
||||||
|
paused={paused}
|
||||||
|
onEnded={next}
|
||||||
|
onProgress={setProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'paper':
|
||||||
|
return (
|
||||||
|
<PaperParticleView
|
||||||
|
key={particle.id}
|
||||||
|
particle={particle}
|
||||||
|
paused={paused}
|
||||||
|
onEnded={next}
|
||||||
|
onProgress={setProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'file':
|
||||||
|
return (
|
||||||
|
<FileParticleView
|
||||||
|
key={particle.id}
|
||||||
|
particle={particle}
|
||||||
|
paused={paused}
|
||||||
|
onEnded={next}
|
||||||
|
/>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<FallbackParticleView
|
<FallbackParticleView
|
||||||
@@ -544,31 +579,6 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Top status pills: paused + exit countdown. Anchored just below
|
|
||||||
the metadata row (avatar + name ≈ 40px tall, starts at
|
|
||||||
insets.top + 32) so they share the top chrome real estate
|
|
||||||
instead of competing with captions at the bottom. */}
|
|
||||||
<View
|
|
||||||
pointerEvents="none"
|
|
||||||
className="absolute inset-x-0 items-center"
|
|
||||||
style={{ top: insets.top + 88 }}
|
|
||||||
>
|
|
||||||
{paused ? (
|
|
||||||
<View className="bg-white/15 rounded-full px-3 py-1">
|
|
||||||
<Text className="text-white/90 text-xs font-medium">
|
|
||||||
Paused
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
{exitRemainingMs !== null ? (
|
|
||||||
<View className="bg-white/15 rounded-full px-3 py-1 mt-2">
|
|
||||||
<Text className="text-white/90 text-xs font-medium">
|
|
||||||
Closing in {Math.ceil(exitRemainingMs / 1000)}s
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
|
|
||||||
@@ -624,6 +634,20 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Playback status pills — pinned to the right chrome margin just
|
||||||
|
below the top actions row so they live in the same band as the
|
||||||
|
chrome buttons instead of overlaying the particle canvas. */}
|
||||||
|
<View
|
||||||
|
pointerEvents="none"
|
||||||
|
className="absolute right-3"
|
||||||
|
style={{ top: insets.top + 72 }}
|
||||||
|
>
|
||||||
|
<StreamStatusPills
|
||||||
|
paused={paused}
|
||||||
|
exitRemainingMs={exitRemainingMs}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Right-edge reaction stack — mirrors desktop's ReactionBar. Vertically
|
{/* Right-edge reaction stack — mirrors desktop's ReactionBar. Vertically
|
||||||
centered on the canvas; outside the GestureDetector so each pill
|
centered on the canvas; outside the GestureDetector so each pill
|
||||||
tap toggles cleanly without competing with the stream advance/back
|
tap toggles cleanly without competing with the stream advance/back
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Keyboard,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { deleteField } from 'firebase/firestore';
|
||||||
|
import { Check, Plus, X } from 'lucide-react-native';
|
||||||
|
import type { ChecklistItem, Particle } from '@/api/types';
|
||||||
|
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||||
|
import {
|
||||||
|
updateParticle,
|
||||||
|
updateParticleProperties,
|
||||||
|
} from '@/lib/firestore-particles';
|
||||||
|
import { useNetwork } from '@/hooks/use-networks';
|
||||||
|
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||||
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useStreamSafeArea } from './stream-safe-area';
|
||||||
|
|
||||||
|
type TaskParticle = Extract<Particle, { type: 'task' }>;
|
||||||
|
|
||||||
|
interface TaskParticleViewProps {
|
||||||
|
particle: TaskParticle;
|
||||||
|
networkId: string;
|
||||||
|
streamId: string;
|
||||||
|
paused: boolean;
|
||||||
|
onEnded: () => void;
|
||||||
|
onProgress: (ratio: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DWELL_DURATION_S = 8;
|
||||||
|
const TICK_MS = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editable task card. Mirrors desktop's task-particle-view — round done
|
||||||
|
* checkbox + title, notes, a checklist, and an assignee picker — persisting
|
||||||
|
* each edit straight to Firestore. A fixed 8s dwell auto-advances the stream;
|
||||||
|
* focusing any field suspends playback so typing isn't raced by the timer.
|
||||||
|
*/
|
||||||
|
export function TaskParticleView({
|
||||||
|
particle,
|
||||||
|
networkId,
|
||||||
|
streamId,
|
||||||
|
paused,
|
||||||
|
onEnded,
|
||||||
|
onProgress,
|
||||||
|
}: TaskParticleViewProps) {
|
||||||
|
const network = useNetwork(networkId);
|
||||||
|
const safe = useStreamSafeArea();
|
||||||
|
const docPath = toFirestoreDocPath(
|
||||||
|
particlePath(networkId, [streamId, particle.id]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
notes,
|
||||||
|
checklist = [],
|
||||||
|
assigned_to,
|
||||||
|
done,
|
||||||
|
} = particle.properties;
|
||||||
|
|
||||||
|
// Suspend the dwell timer whenever a field is focused so typing isn't
|
||||||
|
// interrupted by an auto-advance. Mirrors desktop's `editing` suspender.
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
useSuspendPlayback(editing, `task-edit-${particle.id}`);
|
||||||
|
|
||||||
|
// --- Fixed dwell (mirrors TextParticleView's interval cadence) ---
|
||||||
|
const elapsedRef = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
elapsedRef.current = 0;
|
||||||
|
onProgress(0);
|
||||||
|
}, [particle.id, onProgress]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused) return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
elapsedRef.current += TICK_MS / 1000;
|
||||||
|
const ratio = Math.min(elapsedRef.current / DWELL_DURATION_S, 1);
|
||||||
|
onProgress(ratio);
|
||||||
|
if (ratio >= 1) {
|
||||||
|
clearInterval(interval);
|
||||||
|
onEnded();
|
||||||
|
}
|
||||||
|
}, TICK_MS);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [paused, onEnded, onProgress, particle.id]);
|
||||||
|
|
||||||
|
// Checklist writes replace the whole array; concurrent edits are
|
||||||
|
// last-write-wins (same tradeoff desktop documents). Keep a ref so a second
|
||||||
|
// edit composes on the latest local base before the next snapshot arrives.
|
||||||
|
const checklistRef = useRef(checklist);
|
||||||
|
useEffect(() => {
|
||||||
|
checklistRef.current = checklist;
|
||||||
|
}, [checklist]);
|
||||||
|
|
||||||
|
const writeChecklist = useCallback(
|
||||||
|
(items: ChecklistItem[]) => {
|
||||||
|
checklistRef.current = items;
|
||||||
|
return updateParticleProperties<'task'>(docPath, { checklist: items });
|
||||||
|
},
|
||||||
|
[docPath],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleDone = useCallback(
|
||||||
|
() => updateParticleProperties<'task'>(docPath, { done: !done }),
|
||||||
|
[docPath, done],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleItem = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const items = checklistRef.current.map((item, i) =>
|
||||||
|
i === index ? { ...item, done: !item.done } : item,
|
||||||
|
);
|
||||||
|
void writeChecklist(items);
|
||||||
|
},
|
||||||
|
[writeChecklist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCommitItemText = useCallback(
|
||||||
|
(index: number, text: string) => {
|
||||||
|
const items = checklistRef.current.map((item, i) =>
|
||||||
|
i === index ? { ...item, text } : item,
|
||||||
|
);
|
||||||
|
void writeChecklist(items);
|
||||||
|
},
|
||||||
|
[writeChecklist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemoveItem = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
void writeChecklist(checklistRef.current.filter((_, i) => i !== index));
|
||||||
|
},
|
||||||
|
[writeChecklist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddItem = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
void writeChecklist([...checklistRef.current, { text, done: false }]);
|
||||||
|
},
|
||||||
|
[writeChecklist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAssign = useCallback(
|
||||||
|
(humanId: string | null) => {
|
||||||
|
if (!humanId) {
|
||||||
|
void updateParticle(docPath, 'properties.assigned_to', deleteField());
|
||||||
|
} else {
|
||||||
|
void updateParticleProperties<'task'>(docPath, {
|
||||||
|
assigned_to: humanId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[docPath],
|
||||||
|
);
|
||||||
|
|
||||||
|
const doneCount = checklist.filter((item) => item.done).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className="flex-1 items-center justify-center px-6"
|
||||||
|
style={{ paddingTop: safe.top + 16, paddingBottom: safe.bottom + 16 }}
|
||||||
|
>
|
||||||
|
<View className="w-full max-w-xl" style={{ maxHeight: '100%' }}>
|
||||||
|
<ScrollView
|
||||||
|
className="max-h-full rounded-2xl bg-white/10"
|
||||||
|
contentContainerClassName="px-5 py-5 gap-5"
|
||||||
|
showsVerticalScrollIndicator
|
||||||
|
indicatorStyle="white"
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
// Dragging the card dismisses the keyboard (interactive follow on
|
||||||
|
// iOS; on-drag on Android, which lacks the interactive variant).
|
||||||
|
keyboardDismissMode={
|
||||||
|
Platform.OS === 'ios' ? 'interactive' : 'on-drag'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Title + done */}
|
||||||
|
<View
|
||||||
|
className={cn('flex-row items-start gap-3', editing && 'pr-12')}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={handleToggleDone}
|
||||||
|
hitSlop={8}
|
||||||
|
accessibilityLabel={done ? 'Mark not done' : 'Mark done'}
|
||||||
|
className={cn(
|
||||||
|
'mt-1 h-6 w-6 items-center justify-center rounded-full border',
|
||||||
|
done ? 'bg-emerald-500 border-emerald-500' : 'border-white/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{done ? <Check color="white" size={14} strokeWidth={3} /> : null}
|
||||||
|
</Pressable>
|
||||||
|
<TextInput
|
||||||
|
defaultValue={title}
|
||||||
|
key={`title-${particle.id}`}
|
||||||
|
onFocus={() => setEditing(true)}
|
||||||
|
onBlur={() => setEditing(false)}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const value = e.nativeEvent.text;
|
||||||
|
if (value !== title) {
|
||||||
|
void updateParticleProperties<'task'>(docPath, {
|
||||||
|
title: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Task title"
|
||||||
|
placeholderTextColor="rgba(255,255,255,0.3)"
|
||||||
|
multiline
|
||||||
|
className={cn(
|
||||||
|
'flex-1 text-white text-2xl font-semibold',
|
||||||
|
done && 'text-white/50 line-through',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<TextInput
|
||||||
|
defaultValue={notes ?? ''}
|
||||||
|
key={`notes-${particle.id}`}
|
||||||
|
onFocus={() => setEditing(true)}
|
||||||
|
onBlur={() => setEditing(false)}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const value = e.nativeEvent.text;
|
||||||
|
if (value !== (notes ?? '')) {
|
||||||
|
void updateParticleProperties<'task'>(docPath, {
|
||||||
|
notes: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Add notes…"
|
||||||
|
placeholderTextColor="rgba(255,255,255,0.3)"
|
||||||
|
multiline
|
||||||
|
className="text-white/80 text-base"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Checklist */}
|
||||||
|
<View className="gap-2">
|
||||||
|
{checklist.length > 0 ? (
|
||||||
|
<Text className="text-white/40 text-xs">
|
||||||
|
{doneCount} / {checklist.length} done
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{checklist.map((item, index) => (
|
||||||
|
<ChecklistItemRow
|
||||||
|
key={`${particle.id}-item-${index}`}
|
||||||
|
item={item}
|
||||||
|
onToggle={() => handleToggleItem(index)}
|
||||||
|
onCommitText={(text) => handleCommitItemText(index, text)}
|
||||||
|
onRemove={() => handleRemoveItem(index)}
|
||||||
|
onFocusChange={setEditing}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<AddChecklistItemRow
|
||||||
|
onAdd={handleAddItem}
|
||||||
|
onFocusChange={setEditing}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Assignee */}
|
||||||
|
<View className="gap-2">
|
||||||
|
<Text className="text-white/40 text-xs">Assignee</Text>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerClassName="gap-2"
|
||||||
|
>
|
||||||
|
<AssigneeChip
|
||||||
|
label="Unassigned"
|
||||||
|
selected={!assigned_to}
|
||||||
|
onPress={() => handleAssign(null)}
|
||||||
|
/>
|
||||||
|
{network?.humans?.map((human) => {
|
||||||
|
const display = resolveHumanDisplay(human.id, network?.humans);
|
||||||
|
return (
|
||||||
|
<AssigneeChip
|
||||||
|
key={human.id}
|
||||||
|
label={display.displayName}
|
||||||
|
selected={assigned_to === human.id}
|
||||||
|
onPress={() => handleAssign(human.id)}
|
||||||
|
avatarHumanId={human.id}
|
||||||
|
humans={network?.humans}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* While a field is focused the keyboard hides the stream's tap-zones,
|
||||||
|
so offer an explicit way out. Dismissing blurs the active field,
|
||||||
|
which flips `editing` off and resumes the dwell + tap navigation. */}
|
||||||
|
{editing ? (
|
||||||
|
<View className="absolute right-2 top-2">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => Keyboard.dismiss()}
|
||||||
|
hitSlop={8}
|
||||||
|
accessibilityLabel="Done editing"
|
||||||
|
className="rounded-full bg-white/15 px-3 py-1.5"
|
||||||
|
>
|
||||||
|
<Text className="text-white text-sm font-medium">Done</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChecklistItemRow({
|
||||||
|
item,
|
||||||
|
onToggle,
|
||||||
|
onCommitText,
|
||||||
|
onRemove,
|
||||||
|
onFocusChange,
|
||||||
|
}: {
|
||||||
|
item: ChecklistItem;
|
||||||
|
onToggle: () => void;
|
||||||
|
onCommitText: (text: string) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onFocusChange: (focused: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center gap-2.5">
|
||||||
|
<Pressable
|
||||||
|
onPress={onToggle}
|
||||||
|
hitSlop={6}
|
||||||
|
accessibilityLabel={
|
||||||
|
item.done ? 'Mark subtask not done' : 'Mark subtask done'
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'h-5 w-5 items-center justify-center rounded border',
|
||||||
|
item.done ? 'bg-emerald-500 border-emerald-500' : 'border-white/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.done ? <Check color="white" size={12} strokeWidth={3} /> : null}
|
||||||
|
</Pressable>
|
||||||
|
<TextInput
|
||||||
|
defaultValue={item.text}
|
||||||
|
onFocus={() => onFocusChange(true)}
|
||||||
|
onBlur={() => onFocusChange(false)}
|
||||||
|
onEndEditing={(e) => {
|
||||||
|
const value = e.nativeEvent.text;
|
||||||
|
if (value !== item.text) onCommitText(value);
|
||||||
|
}}
|
||||||
|
placeholder="Subtask"
|
||||||
|
placeholderTextColor="rgba(255,255,255,0.3)"
|
||||||
|
className={cn(
|
||||||
|
'flex-1 text-white/90 text-sm',
|
||||||
|
item.done && 'text-white/40 line-through',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={onRemove}
|
||||||
|
hitSlop={6}
|
||||||
|
accessibilityLabel="Remove subtask"
|
||||||
|
>
|
||||||
|
<X color="rgba(255,255,255,0.4)" size={14} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddChecklistItemRow({
|
||||||
|
onAdd,
|
||||||
|
onFocusChange,
|
||||||
|
}: {
|
||||||
|
onAdd: (text: string) => void;
|
||||||
|
onFocusChange: (focused: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const text = draft.trim();
|
||||||
|
if (!text) return;
|
||||||
|
onAdd(text);
|
||||||
|
setDraft('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center gap-2.5">
|
||||||
|
<Plus color="rgba(255,255,255,0.3)" size={16} />
|
||||||
|
<TextInput
|
||||||
|
value={draft}
|
||||||
|
onChangeText={setDraft}
|
||||||
|
onFocus={() => onFocusChange(true)}
|
||||||
|
onBlur={() => onFocusChange(false)}
|
||||||
|
onSubmitEditing={submit}
|
||||||
|
blurOnSubmit={false}
|
||||||
|
placeholder="Add subtask…"
|
||||||
|
placeholderTextColor="rgba(255,255,255,0.3)"
|
||||||
|
className="flex-1 text-white/70 text-sm"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssigneeChip({
|
||||||
|
label,
|
||||||
|
selected,
|
||||||
|
onPress,
|
||||||
|
avatarHumanId,
|
||||||
|
humans,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
selected: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
avatarHumanId?: string;
|
||||||
|
humans?: import('@/api/types').Human[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
className={cn(
|
||||||
|
'flex-row items-center gap-2 rounded-full px-3 py-2',
|
||||||
|
selected ? 'bg-white' : 'bg-white/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{avatarHumanId ? (
|
||||||
|
<Avatar humanId={avatarHumanId} humans={humans} size="xs" />
|
||||||
|
) : null}
|
||||||
|
<Text
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium',
|
||||||
|
selected ? 'text-black' : 'text-white/80',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useRef, type ReactNode } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Platform, ScrollView, Text, View, type ViewStyle } from 'react-native';
|
import { ScrollView, Text, View } from 'react-native';
|
||||||
import { Renderer, useMarkdown, type MarkedStyles } from 'react-native-marked';
|
|
||||||
import type { Particle } from '@/api/types';
|
import type { Particle } from '@/api/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { MarkdownBody } from '@/components/MarkdownBody';
|
||||||
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
|
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
|
||||||
import { useStreamSafeArea } from './stream-safe-area';
|
import { useStreamSafeArea } from './stream-safe-area';
|
||||||
|
|
||||||
@@ -47,156 +47,6 @@ function hasMarkdownFormatting(content: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// react-native-marked doesn't render GFM task-list checkboxes (marked strips
|
|
||||||
// the `[ ]`/`[x]` into token flags the parser ignores), so a write/read drift
|
|
||||||
// shows up as bullets with no box. Swap the marker for a checkbox glyph before
|
|
||||||
// parsing — read-only, matching desktop's bullet-free checkboxes.
|
|
||||||
const TASK_ITEM_RE = /^(\s*)[-*+] \[([ xX])\] /gm;
|
|
||||||
|
|
||||||
function withTaskCheckboxes(markdown: string): string {
|
|
||||||
return markdown.replace(
|
|
||||||
TASK_ITEM_RE,
|
|
||||||
(_match, indent: string, mark: string) =>
|
|
||||||
`${indent}${mark === ' ' ? '☐' : '☑'} `,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mirror the desktop Crepe palette (markdown-editor.css `--crepe-*`) so a
|
|
||||||
// message reads the same on both surfaces: white-on-transparent text, a blue
|
|
||||||
// accent, pink inline code, and a near-opaque dark surface behind code blocks
|
|
||||||
// and tables. Defined at module scope so the references stay stable —
|
|
||||||
// `useMarkdown` re-parses only when these or the content change.
|
|
||||||
//
|
|
||||||
// Known gap vs desktop: fenced code blocks aren't syntax-highlighted (Crepe
|
|
||||||
// uses CodeMirror; react-native-marked only exposes the language tag). They
|
|
||||||
// render as plain monospace on the dark surface, which is acceptable for v1.
|
|
||||||
const TEXT_COLOR = 'rgba(255,255,255,0.92)';
|
|
||||||
const ACCENT = '#60a5fa';
|
|
||||||
const SURFACE = 'rgba(24,24,28,0.96)';
|
|
||||||
const OUTLINE = 'rgba(255,255,255,0.2)';
|
|
||||||
const MONO = Platform.OS === 'ios' ? 'Menlo' : 'monospace';
|
|
||||||
|
|
||||||
const MARKDOWN_THEME = {
|
|
||||||
colors: {
|
|
||||||
text: TEXT_COLOR,
|
|
||||||
link: ACCENT,
|
|
||||||
code: SURFACE,
|
|
||||||
border: OUTLINE,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const MARKDOWN_STYLES: MarkedStyles = {
|
|
||||||
text: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
|
|
||||||
li: { color: TEXT_COLOR, fontSize: 18, lineHeight: 28 },
|
|
||||||
strong: { fontWeight: '700' },
|
|
||||||
em: { fontStyle: 'italic' },
|
|
||||||
strikethrough: {
|
|
||||||
textDecorationLine: 'line-through',
|
|
||||||
color: 'rgba(255,255,255,0.6)',
|
|
||||||
},
|
|
||||||
// fontStyle "normal" cancels react-native-marked's italic-by-default for
|
|
||||||
// links and inline code (desktop renders neither italic).
|
|
||||||
link: { color: ACCENT, fontStyle: 'normal' },
|
|
||||||
// borderBottomWidth 0 removes the library's default heading underline rule,
|
|
||||||
// which desktop's headings don't have.
|
|
||||||
h1: {
|
|
||||||
color: '#ffffff',
|
|
||||||
fontSize: 28,
|
|
||||||
lineHeight: 34,
|
|
||||||
fontWeight: '700',
|
|
||||||
marginTop: 8,
|
|
||||||
marginBottom: 8,
|
|
||||||
borderBottomWidth: 0,
|
|
||||||
},
|
|
||||||
h2: {
|
|
||||||
color: '#ffffff',
|
|
||||||
fontSize: 24,
|
|
||||||
lineHeight: 30,
|
|
||||||
fontWeight: '700',
|
|
||||||
marginTop: 8,
|
|
||||||
marginBottom: 6,
|
|
||||||
borderBottomWidth: 0,
|
|
||||||
},
|
|
||||||
h3: {
|
|
||||||
color: '#ffffff',
|
|
||||||
fontSize: 20,
|
|
||||||
lineHeight: 26,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginTop: 6,
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
h4: {
|
|
||||||
color: '#ffffff',
|
|
||||||
fontSize: 18,
|
|
||||||
lineHeight: 24,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginTop: 6,
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
h5: {
|
|
||||||
color: '#ffffff',
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeight: 22,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginTop: 4,
|
|
||||||
marginBottom: 2,
|
|
||||||
},
|
|
||||||
h6: {
|
|
||||||
color: 'rgba(255,255,255,0.7)',
|
|
||||||
fontSize: 15,
|
|
||||||
lineHeight: 20,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginTop: 4,
|
|
||||||
marginBottom: 2,
|
|
||||||
},
|
|
||||||
codespan: {
|
|
||||||
color: '#fca5a5',
|
|
||||||
fontFamily: MONO,
|
|
||||||
fontStyle: 'normal',
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
|
||||||
},
|
|
||||||
code: {
|
|
||||||
backgroundColor: SURFACE,
|
|
||||||
borderColor: OUTLINE,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 12,
|
|
||||||
marginVertical: 6,
|
|
||||||
},
|
|
||||||
blockquote: {
|
|
||||||
borderLeftWidth: 3,
|
|
||||||
borderLeftColor: OUTLINE,
|
|
||||||
paddingLeft: 12,
|
|
||||||
marginVertical: 6,
|
|
||||||
opacity: 0.85,
|
|
||||||
},
|
|
||||||
// hr is left to the library default, which already draws a 1px rule in the
|
|
||||||
// themed border color (OUTLINE).
|
|
||||||
table: { borderWidth: 1, borderColor: OUTLINE, marginVertical: 6 },
|
|
||||||
tableRow: { borderColor: OUTLINE },
|
|
||||||
tableCell: { borderColor: OUTLINE, padding: 8 },
|
|
||||||
};
|
|
||||||
|
|
||||||
// react-native-marked feeds fenced code blocks the `em` (italic, proportional)
|
|
||||||
// text style, so out of the box code renders italic in the body font. Override
|
|
||||||
// `code` to apply a monospace, non-italic style instead — matching desktop's
|
|
||||||
// code blocks. Instantiated once at module scope to keep the reference stable
|
|
||||||
// for `useMarkdown`'s memoization.
|
|
||||||
const CODE_TEXT_STYLE = {
|
|
||||||
color: TEXT_COLOR,
|
|
||||||
fontFamily: MONO,
|
|
||||||
fontSize: 15,
|
|
||||||
lineHeight: 22,
|
|
||||||
};
|
|
||||||
|
|
||||||
class MarkdownRenderer extends Renderer {
|
|
||||||
code(text: string, language?: string, containerStyle?: ViewStyle): ReactNode {
|
|
||||||
return super.code(text, language, containerStyle, CODE_TEXT_STYLE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const MARKDOWN_RENDERER = new MarkdownRenderer();
|
|
||||||
|
|
||||||
export function TextParticleView({
|
export function TextParticleView({
|
||||||
particle,
|
particle,
|
||||||
paused,
|
paused,
|
||||||
@@ -208,11 +58,6 @@ export function TextParticleView({
|
|||||||
const durationS = computeReadDuration(content);
|
const durationS = computeReadDuration(content);
|
||||||
const elapsedRef = useRef(0);
|
const elapsedRef = useRef(0);
|
||||||
const safe = useStreamSafeArea();
|
const safe = useStreamSafeArea();
|
||||||
const markdownNodes = useMarkdown(withTaskCheckboxes(content), {
|
|
||||||
renderer: MARKDOWN_RENDERER,
|
|
||||||
theme: MARKDOWN_THEME,
|
|
||||||
styles: MARKDOWN_STYLES,
|
|
||||||
});
|
|
||||||
|
|
||||||
const editedLabel = editedAt ? (
|
const editedLabel = editedAt ? (
|
||||||
<View className="mt-3 items-center">
|
<View className="mt-3 items-center">
|
||||||
@@ -290,7 +135,7 @@ export function TextParticleView({
|
|||||||
showsVerticalScrollIndicator
|
showsVerticalScrollIndicator
|
||||||
indicatorStyle="white"
|
indicatorStyle="white"
|
||||||
>
|
>
|
||||||
{markdownNodes}
|
<MarkdownBody content={content} />
|
||||||
{editedLabel}
|
{editedLabel}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export function NewStreamScreen({
|
|||||||
networkId={networkId}
|
networkId={networkId}
|
||||||
targetPath={placeholderPath}
|
targetPath={placeholderPath}
|
||||||
silentPresence
|
silentPresence
|
||||||
|
allowTask={false}
|
||||||
submitMedia={submitMedia}
|
submitMedia={submitMedia}
|
||||||
submitText={submitText}
|
submitText={submitText}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { Pressable, Text, View } from 'react-native';
|
|||||||
import { Headphones } from 'lucide-react-native';
|
import { Headphones } from 'lucide-react-native';
|
||||||
import type { Particle, StreamProperties } from '@/api/types';
|
import type { Particle, StreamProperties } from '@/api/types';
|
||||||
import { isParticleDeleted } from '@/api/types';
|
import { isParticleDeleted } from '@/api/types';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
|
import { RelativeTimestamp } from '@/components/RelativeTimestamp';
|
||||||
import { useLiveLatestChild } from '@/hooks/use-particle';
|
import { useLiveLatestChild } from '@/hooks/use-particle';
|
||||||
import { useNetwork } from '@/hooks/use-networks';
|
import { useNetwork } from '@/hooks/use-networks';
|
||||||
import { particlePath } from '@/lib/particle-path';
|
import { particlePath } from '@/lib/particle-path';
|
||||||
import { cn, getInitials } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
interface StreamCardProps {
|
interface StreamCardProps {
|
||||||
@@ -35,34 +36,20 @@ export const StreamCard = memo(function StreamCard({
|
|||||||
particle.visible_to.length === 2 &&
|
particle.visible_to.length === 2 &&
|
||||||
particle.visible_to.every((v) => v.startsWith('human:'));
|
particle.visible_to.every((v) => v.startsWith('human:'));
|
||||||
|
|
||||||
const initials = useMemo(() => {
|
// The human represented by the card: the other party in a DM, otherwise the
|
||||||
|
// author of the latest message. Group streams with no messages resolve to
|
||||||
|
// null and fall back to the stream-name initials below.
|
||||||
|
const avatarHumanId = useMemo(() => {
|
||||||
if (isDM) {
|
if (isDM) {
|
||||||
const otherEntry = particle.visible_to.find(
|
const otherEntry = particle.visible_to.find(
|
||||||
(v) => v !== `human:${userId}`,
|
(v) => v !== `human:${userId}`,
|
||||||
);
|
);
|
||||||
if (otherEntry) {
|
if (otherEntry) return otherEntry.replace('human:', '');
|
||||||
const otherId = otherEntry.replace('human:', '');
|
|
||||||
const otherHuman = network?.humans?.find((h) => h.id === otherId);
|
|
||||||
if (otherHuman) return getInitials(otherHuman.email);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return latestChild?.created_by_human_id ?? null;
|
||||||
|
}, [isDM, particle.visible_to, userId, latestChild]);
|
||||||
|
|
||||||
if (latestChild) {
|
const fallbackInitials = particle.properties.name.slice(0, 2).toUpperCase();
|
||||||
const creator = network?.humans?.find(
|
|
||||||
(h) => h.id === latestChild.created_by_human_id,
|
|
||||||
);
|
|
||||||
if (creator) return getInitials(creator.email);
|
|
||||||
}
|
|
||||||
|
|
||||||
return particle.properties.name.slice(0, 2).toUpperCase();
|
|
||||||
}, [
|
|
||||||
isDM,
|
|
||||||
particle.visible_to,
|
|
||||||
particle.properties.name,
|
|
||||||
userId,
|
|
||||||
latestChild,
|
|
||||||
network,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isUnseen = useMemo(() => {
|
const isUnseen = useMemo(() => {
|
||||||
if (!latestChild) return false;
|
if (!latestChild) return false;
|
||||||
@@ -87,7 +74,7 @@ export const StreamCard = memo(function StreamCard({
|
|||||||
return latestChild.properties.content;
|
return latestChild.properties.content;
|
||||||
case 'file':
|
case 'file':
|
||||||
return latestChild.properties.filename;
|
return latestChild.properties.filename;
|
||||||
case 'quest':
|
case 'task':
|
||||||
return latestChild.properties.title;
|
return latestChild.properties.title;
|
||||||
case 'paper':
|
case 'paper':
|
||||||
return latestChild.properties.title;
|
return latestChild.properties.title;
|
||||||
@@ -102,21 +89,12 @@ export const StreamCard = memo(function StreamCard({
|
|||||||
android_ripple={{ color: 'rgba(0,0,0,0.05)' }}
|
android_ripple={{ color: 'rgba(0,0,0,0.05)' }}
|
||||||
className="bg-card flex-row items-center gap-3 px-4 py-3 active:bg-accent"
|
className="bg-card flex-row items-center gap-3 px-4 py-3 active:bg-accent"
|
||||||
>
|
>
|
||||||
<View
|
<Avatar
|
||||||
className={cn(
|
humanId={avatarHumanId}
|
||||||
'h-10 w-10 items-center justify-center rounded-full',
|
humans={network?.humans}
|
||||||
isUnseen ? 'bg-primary' : 'bg-muted',
|
size="md"
|
||||||
)}
|
fallbackInitials={fallbackInitials}
|
||||||
>
|
/>
|
||||||
<Text
|
|
||||||
className={cn(
|
|
||||||
'text-xs font-semibold',
|
|
||||||
isUnseen ? 'text-primary-foreground' : 'text-muted-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{initials}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex-1">
|
<View className="flex-1">
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { Settings as SettingsIcon } from 'lucide-react-native';
|
||||||
import { ListSeparator } from '@/components/ListSeparator';
|
import { ListSeparator } from '@/components/ListSeparator';
|
||||||
import { toUserMessage } from '@/lib/errors';
|
import { toUserMessage } from '@/lib/errors';
|
||||||
import { particlePath } from '@/lib/particle-path';
|
import { particlePath } from '@/lib/particle-path';
|
||||||
@@ -32,6 +33,9 @@ export function StreamListScreen({
|
|||||||
<Header
|
<Header
|
||||||
title={network?.name ?? 'Streams'}
|
title={network?.name ?? 'Streams'}
|
||||||
onBack={() => navigation.goBack()}
|
onBack={() => navigation.goBack()}
|
||||||
|
onOpenSettings={() =>
|
||||||
|
navigation.navigate('NetworkSettings', { networkId })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -67,7 +71,15 @@ export function StreamListScreen({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Header({ title, onBack }: { title: string; onBack: () => void }) {
|
function Header({
|
||||||
|
title,
|
||||||
|
onBack,
|
||||||
|
onOpenSettings,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onBack: () => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -83,7 +95,14 @@ function Header({ title, onBack }: { title: string; onBack: () => void }) {
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<View className="w-8" />
|
<Pressable
|
||||||
|
onPress={onOpenSettings}
|
||||||
|
className="px-2 py-1"
|
||||||
|
accessibilityLabel="Network settings"
|
||||||
|
hitSlop={8}
|
||||||
|
>
|
||||||
|
<SettingsIcon size={20} color="#fafafa" />
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 'media':
|
||||||
case 'file':
|
case 'file':
|
||||||
case 'text':
|
case 'text':
|
||||||
case 'quest':
|
case 'task':
|
||||||
case 'paper': {
|
case 'paper': {
|
||||||
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
|
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
|
||||||
// particles carry `properties.edited_at`, so coerce it if present.
|
// particles carry `properties.edited_at`, so coerce it if present.
|
||||||
|
|||||||
@@ -103,6 +103,32 @@ export async function createTextParticle({
|
|||||||
return createParticle(collectionPath, 'text', { content }, createdByHumanId);
|
return createParticle(collectionPath, 'text', { content }, createdByHumanId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CreateTaskParticleParams {
|
||||||
|
targetPath: ParticlePath;
|
||||||
|
title: string;
|
||||||
|
notes?: string;
|
||||||
|
createdByHumanId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a `task` particle. Mirrors createTextParticle — the checklist and
|
||||||
|
* assignee are left empty and edited inline in the task card afterwards.
|
||||||
|
*/
|
||||||
|
export async function createTaskParticle({
|
||||||
|
targetPath,
|
||||||
|
title,
|
||||||
|
notes,
|
||||||
|
createdByHumanId,
|
||||||
|
}: CreateTaskParticleParams): Promise<string> {
|
||||||
|
const collectionPath = toFirestoreChildrenPath(targetPath);
|
||||||
|
return createParticle(
|
||||||
|
collectionPath,
|
||||||
|
'task',
|
||||||
|
{ title, done: false, ...(notes ? { notes } : {}) },
|
||||||
|
createdByHumanId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function extensionFromMime(mime: string): string {
|
function extensionFromMime(mime: string): string {
|
||||||
if (mime === 'video/mp4') return '.mp4';
|
if (mime === 'video/mp4') return '.mp4';
|
||||||
if (mime === 'video/quicktime') return '.mov';
|
if (mime === 'video/quicktime') return '.mov';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { StreamListScreen } from '@/features/streams/StreamListScreen';
|
|||||||
import { NewStreamScreen } from '@/features/streams/NewStreamScreen';
|
import { NewStreamScreen } from '@/features/streams/NewStreamScreen';
|
||||||
import { StreamViewScreen } from '@/features/stream-view/StreamViewScreen';
|
import { StreamViewScreen } from '@/features/stream-view/StreamViewScreen';
|
||||||
import { HuddleScreen } from '@/features/huddle/HuddleScreen';
|
import { HuddleScreen } from '@/features/huddle/HuddleScreen';
|
||||||
|
import { NetworkSettingsScreen } from '@/features/network-settings/NetworkSettingsScreen';
|
||||||
import { SettingsScreen } from '@/features/settings/SettingsScreen';
|
import { SettingsScreen } from '@/features/settings/SettingsScreen';
|
||||||
import { AccountScreen } from '@/features/settings/AccountScreen';
|
import { AccountScreen } from '@/features/settings/AccountScreen';
|
||||||
import type { RootStackParamList } from './types';
|
import type { RootStackParamList } from './types';
|
||||||
@@ -54,6 +55,11 @@ export function RootNavigator() {
|
|||||||
component={NewStreamScreen}
|
component={NewStreamScreen}
|
||||||
options={{ animation: 'slide_from_bottom' }}
|
options={{ animation: 'slide_from_bottom' }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="NetworkSettings"
|
||||||
|
component={NetworkSettingsScreen}
|
||||||
|
options={{ animation: 'slide_from_right' }}
|
||||||
|
/>
|
||||||
<Stack.Screen name="Settings" component={SettingsScreen} />
|
<Stack.Screen name="Settings" component={SettingsScreen} />
|
||||||
<Stack.Screen name="Account" component={AccountScreen} />
|
<Stack.Screen name="Account" component={AccountScreen} />
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type RootStackParamList = {
|
|||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
};
|
};
|
||||||
NewStream: { networkId: string };
|
NewStream: { networkId: string };
|
||||||
|
NetworkSettings: { networkId: string };
|
||||||
Settings: undefined;
|
Settings: undefined;
|
||||||
Account: undefined;
|
Account: undefined;
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user