Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba0fbcb4ba | ||
|
|
3175b04d3c | ||
|
|
368922a2c1 | ||
|
|
3381ff0b02 | ||
|
|
03ec264c93 |
@@ -5,4 +5,3 @@ 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_1ToweOJu6RWBXAm2w2wTo8VL"
|
value: "price_1TMBElJu6RWBXAm2pPthUoh6"
|
||||||
- name: "STRIPE_PRICE_PRO_ANNUAL"
|
- name: "STRIPE_PRICE_PRO_ANNUAL"
|
||||||
value: "price_1TowexJu6RWBXAm2G9M0WJBK"
|
value: "price_1TMBElJu6RWBXAm2TOpzdXk5"
|
||||||
- name: "BILLING_SUCCESS_URL"
|
- name: "BILLING_SUCCESS_URL"
|
||||||
value: "llink://billing/success"
|
value: "llink://billing/success"
|
||||||
- name: "BILLING_CANCEL_URL"
|
- name: "BILLING_CANCEL_URL"
|
||||||
|
|||||||
+1
-17
@@ -1,24 +1,8 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html class="dark">
|
||||||
<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.9.0",
|
"version": "1.7.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,18 +23,12 @@ 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();
|
||||||
@@ -52,10 +46,6 @@ 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="bg-card/70 border-border flex rotate-180 items-center gap-1.5 rounded-full border px-2 py-1 backdrop-blur-sm"
|
className="flex rotate-180 items-center gap-1.5 rounded-full bg-white/10 px-2 py-1 backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<span className="flex gap-0.5">
|
<span className="flex gap-0.5">
|
||||||
<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:0ms]" />
|
||||||
<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:150ms]" />
|
||||||
<span className="bg-muted-foreground size-1 animate-bounce rounded-full [animation-delay:300ms]" />
|
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:300ms]" />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground whitespace-nowrap text-[10px]">
|
<span className="whitespace-nowrap text-[10px] text-white/50">
|
||||||
{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-scrim/60 backdrop-blur-sm"
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
<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="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="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold">{title}</h2>
|
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys="Esc"
|
keys="Esc"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close (or press Esc)"
|
title="Close (or press Esc)"
|
||||||
className="text-muted-foreground text-xs"
|
className="text-xs text-white/30"
|
||||||
>
|
>
|
||||||
to close
|
to close
|
||||||
</KeyHint>
|
</KeyHint>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-muted-foreground text-sm">{description}</div>
|
<div className="text-sm text-white/60">{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}>
|
||||||
<AvatarImage src={url} alt={initials} />
|
{url && <AvatarImage src={url} alt={initials} />}
|
||||||
<AvatarFallback className={fallbackClassName}>{initials}</AvatarFallback>
|
<AvatarFallback className={fallbackClassName}>{initials}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ 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';
|
||||||
|
|
||||||
/** Chip restyle of the design-system Kbd, kept in one place. Tints to the
|
/** Dark-overlay chip restyle of the design-system Kbd, kept in one place. */
|
||||||
surrounding text color so it reads on themed panels and over media alike. */
|
|
||||||
const chipClass =
|
const chipClass =
|
||||||
'rounded bg-current/10 px-1.5 py-0.5 font-mono text-xs font-normal text-current';
|
'rounded bg-white/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"]. */
|
||||||
@@ -69,7 +68,7 @@ export function KeyHint({
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
title={title}
|
title={title}
|
||||||
className={cn(
|
className={cn(
|
||||||
'cursor-pointer rounded transition-opacity hover:opacity-80',
|
'cursor-pointer rounded transition-colors hover:text-white/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-scrim/60 backdrop-blur-sm"
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
{/* Panel */}
|
{/* Panel */}
|
||||||
<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="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="mb-5 flex items-center justify-between">
|
<div className="mb-5 flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold">{title}</h2>
|
<h2 className="text-sm font-semibold text-white/70">{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-muted-foreground text-xs"
|
className="text-xs text-white/30"
|
||||||
>
|
>
|
||||||
to close
|
to close
|
||||||
</KeyHint>
|
</KeyHint>
|
||||||
@@ -68,19 +68,21 @@ 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="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
|
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||||
{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="border-border flex items-center justify-between border-b pb-1 last:border-0 last:pb-0"
|
className="flex items-center justify-between border-b border-white/5 pb-1 last:border-0 last:pb-0"
|
||||||
>
|
>
|
||||||
<span className="text-sm">{binding.description}</span>
|
<span className="text-sm text-white/70">
|
||||||
|
{binding.description}
|
||||||
|
</span>
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys={binding.keys}
|
keys={binding.keys}
|
||||||
className="text-muted-foreground flex items-center gap-1"
|
className="flex items-center gap-1 text-white/60"
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-card text-card-foreground border-border max-w-sm overflow-hidden rounded-2xl border"
|
className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md"
|
||||||
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="text-muted-foreground flex items-center gap-1.5 text-xs">
|
<div className="flex items-center gap-1.5 text-xs text-white/50">
|
||||||
{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">
|
<p className="truncate text-sm font-semibold leading-snug text-white">
|
||||||
{metadata.title}
|
{metadata.title}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{metadata.description && (
|
{metadata.description && (
|
||||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
<p className="line-clamp-2 text-xs leading-relaxed text-white/70">
|
||||||
{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-muted-foreground"
|
className="text-white/70 hover:bg-white/10 hover:text-white"
|
||||||
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-muted-foreground"
|
className="text-white/70 hover:bg-white/10 hover:text-white"
|
||||||
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="bg-card border-border max-w-sm overflow-hidden rounded-2xl border">
|
<div className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md">
|
||||||
<Skeleton className="h-32 w-full rounded-none" />
|
<Skeleton className="h-32 w-full rounded-none bg-white/5" />
|
||||||
<div className="flex flex-col gap-2 p-3">
|
<div className="flex flex-col gap-2 p-3">
|
||||||
<Skeleton className="h-3 w-24" />
|
<Skeleton className="h-3 w-24 bg-white/10" />
|
||||||
<Skeleton className="h-4 w-48" />
|
<Skeleton className="h-4 w-48 bg-white/10" />
|
||||||
<Skeleton className="h-3 w-full" />
|
<Skeleton className="h-3 w-full bg-white/10" />
|
||||||
</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="bg-scrim/80 absolute inset-0 z-50 flex items-center justify-center">
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
|
||||||
<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="mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-zinc-900 shadow-xl">
|
||||||
<div className="border-border flex items-center justify-between border-b px-5 py-4">
|
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
|
||||||
<h2 className="text-base font-medium">{title}</h2>
|
<h2 className="text-base font-medium text-zinc-100">{title}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-zinc-400 hover:text-zinc-200"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</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-muted-foreground text-center text-sm">
|
<p className="text-center text-sm text-zinc-400">
|
||||||
Loading sources...
|
Loading sources...
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -73,17 +73,17 @@ export function ScreenSourcePicker({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-border flex justify-end gap-2 border-t px-5 py-3">
|
<div className="flex justify-end gap-2 border-t border-zinc-700 px-5 py-3">
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="hover:bg-accent text-muted-foreground rounded-md px-4 py-2 text-sm"
|
className="rounded-md px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-800"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
disabled={!selectedId}
|
disabled={!selectedId}
|
||||||
onClick={() => selectedId && onSelect(selectedId)}
|
onClick={() => selectedId && onSelect(selectedId)}
|
||||||
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-40"
|
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"
|
||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
@@ -106,7 +106,7 @@ function SourceSection({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-muted-foreground mb-2 text-xs font-medium uppercase tracking-wide">
|
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-zinc-400">
|
||||||
{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-primary bg-accent'
|
? 'border-blue-500 bg-zinc-800'
|
||||||
: 'border-transparent bg-muted hover:border-border'
|
: 'border-transparent bg-zinc-800/50 hover:border-zinc-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<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="text-muted-foreground truncate px-2 py-1.5 text-xs">
|
<p className="truncate px-2 py-1.5 text-xs text-zinc-300">
|
||||||
{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-scrim/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-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function Slider({
|
|||||||
<SliderPrimitive.Thumb
|
<SliderPrimitive.Thumb
|
||||||
data-slot="slider-thumb"
|
data-slot="slider-thumb"
|
||||||
key={index}
|
key={index}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SliderPrimitive.Root>
|
</SliderPrimitive.Root>
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ 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={isDark ? 'dark' : 'light'}
|
theme="dark"
|
||||||
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="text-muted-foreground rounded"
|
className="dark:hover:bg-white/10 rounded text-white/50"
|
||||||
>
|
>
|
||||||
<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="text-muted-foreground rounded"
|
className="dark:hover:bg-white/10 rounded text-white/50"
|
||||||
>
|
>
|
||||||
{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 text-muted-foreground rounded"
|
className="hover:text-destructive dark:hover:bg-white/10 rounded text-white/50"
|
||||||
>
|
>
|
||||||
<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 bg-scrim/90 fixed inset-0 z-50 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 fixed inset-0 z-50 bg-black/90 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(
|
||||||
'bg-muted group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg',
|
'group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10',
|
||||||
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="text-muted-foreground size-5" />
|
<FileIcon className="size-5 text-white/60" />
|
||||||
<span className="text-muted-foreground max-w-full truncate text-[9px]">
|
<span className="max-w-full truncate text-[9px] text-white/50">
|
||||||
{attachment.file.name}
|
{attachment.file.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground text-[9px]">
|
<span className="text-[9px] text-white/40">
|
||||||
{formatFileSize(attachment.file.size)}
|
{formatFileSize(attachment.file.size)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isUploading && (
|
{isUploading && (
|
||||||
<div className="bg-scrim/50 absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||||
<Loader2 className="size-4 animate-spin text-white" />
|
<Loader2 className="size-4 animate-spin text-white/70" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ function AttachmentThumbnail({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemove();
|
onRemove();
|
||||||
}}
|
}}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<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="bg-muted flex h-16 w-28 shrink-0 flex-col gap-1.5 rounded-lg p-2">
|
<div className="flex h-16 w-28 shrink-0 flex-col gap-1.5 rounded-lg bg-white/10 p-2">
|
||||||
<Skeleton className="h-2 w-16" />
|
<Skeleton className="h-2 w-16 bg-white/10" />
|
||||||
<Skeleton className="h-3 w-24" />
|
<Skeleton className="h-3 w-24 bg-white/10" />
|
||||||
</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="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"
|
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"
|
||||||
>
|
>
|
||||||
<div className="text-muted-foreground flex items-center gap-1 text-[10px]">
|
<div className="flex items-center gap-1 text-[10px] text-white/40">
|
||||||
{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="text-foreground line-clamp-2 text-[11px] font-medium leading-tight">
|
<p className="line-clamp-2 text-[11px] font-medium leading-tight text-white/80">
|
||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -197,7 +197,7 @@ export function AttachmentStrip({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onAddClick();
|
onAddClick();
|
||||||
}}
|
}}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<Plus className="size-5" />
|
<Plus className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -29,11 +29,7 @@ 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 {
|
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from '@/lib/constants';
|
||||||
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';
|
||||||
@@ -63,6 +59,8 @@ 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).
|
||||||
@@ -597,16 +595,6 @@ 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' ||
|
||||||
@@ -614,7 +602,7 @@ export function ComposeOverlay({
|
|||||||
currentStep === 'picking'
|
currentStep === 'picking'
|
||||||
) {
|
) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
consume();
|
e.preventDefault();
|
||||||
cancel();
|
cancel();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -632,19 +620,19 @@ export function ComposeOverlay({
|
|||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
case 'idle': {
|
case 'idle': {
|
||||||
if (e.key === '`' && !e.repeat) {
|
if (e.key === '`' && !e.repeat) {
|
||||||
consume();
|
e.preventDefault();
|
||||||
handleRecordIntent();
|
handleRecordIntent();
|
||||||
} else if (e.key === 's' || e.key === 'S') {
|
} else if (e.key === 's' || e.key === 'S') {
|
||||||
consume();
|
e.preventDefault();
|
||||||
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') {
|
||||||
consume();
|
e.preventDefault();
|
||||||
handleTextIntent();
|
handleTextIntent();
|
||||||
} else if (e.key === 'd' || e.key === 'D') {
|
} else if (e.key === 'd' || e.key === 'D') {
|
||||||
consume();
|
e.preventDefault();
|
||||||
handleTaskIntent();
|
handleTaskIntent();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -653,17 +641,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)
|
||||||
consume();
|
e.preventDefault();
|
||||||
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
|
||||||
consume();
|
e.preventDefault();
|
||||||
handleStopIntent();
|
handleStopIntent();
|
||||||
} else if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
|
} else if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
|
||||||
consume();
|
e.preventDefault();
|
||||||
handleCancelIntent();
|
handleCancelIntent();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -671,10 +659,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') {
|
||||||
consume();
|
e.preventDefault();
|
||||||
handleCancelIntent();
|
handleCancelIntent();
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter') {
|
||||||
consume();
|
e.preventDefault();
|
||||||
handleSendIntent();
|
handleSendIntent();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -701,13 +689,10 @@ export function ComposeOverlay({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Capture phase so compose can consume keys before the stream's
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
// 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, true);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
window.removeEventListener('keyup', handleKeyUp);
|
window.removeEventListener('keyup', handleKeyUp);
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
@@ -770,7 +755,7 @@ export function ComposeOverlay({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{step === 'recording' && recordingSource === 'screen' && (
|
{step === 'recording' && recordingSource === 'screen' && (
|
||||||
<div className="bg-scrim/90 absolute inset-0 z-50 flex flex-col items-center justify-center">
|
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
|
||||||
<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" />
|
||||||
@@ -842,8 +827,8 @@ export function ComposeOverlay({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{step === 'submitting' && (
|
{step === 'submitting' && (
|
||||||
<div className="bg-background/95 absolute inset-0 z-50 flex flex-col items-center justify-center backdrop-blur-sm">
|
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
|
||||||
<span className="text-muted-foreground animate-pulse text-sm">
|
<span className="animate-pulse text-sm text-white/60">
|
||||||
Sending...
|
Sending...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,13 +77,13 @@ export function ConfigureContainerStep({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-background/95 absolute inset-0 z-50 flex flex-col pt-16 backdrop-blur-sm"
|
className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16"
|
||||||
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="text-muted-foreground mb-1 text-xs">
|
<Label className="mb-1 text-xs text-white/50">
|
||||||
{kind === 'folder' ? 'Folder name' : 'Stream name'}
|
{kind === 'folder' ? 'Folder name' : 'Stream name'}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -94,22 +94,21 @@ 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="text-muted-foreground mb-1 text-xs">
|
<Label className="mb-1 text-xs text-white/50">Visible to</Label>
|
||||||
Visible to
|
<div className="rounded-md border border-white/10">
|
||||||
</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',
|
||||||
'hover:bg-accent',
|
'text-white/70 hover:bg-white/5',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -138,7 +137,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',
|
||||||
'hover:bg-accent',
|
'text-white/70 hover:bg-white/5',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -146,7 +145,7 @@ export function ConfigureContainerStep({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="pointer-events-none"
|
className="pointer-events-none"
|
||||||
/>
|
/>
|
||||||
<span className="bg-muted flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-medium">
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/10 text-[10px] font-medium">
|
||||||
{initials}
|
{initials}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 truncate">
|
<span className="flex-1 truncate">
|
||||||
@@ -163,7 +162,7 @@ export function ConfigureContainerStep({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Keyboard hints */}
|
{/* Keyboard hints */}
|
||||||
<div className="text-muted-foreground absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm">
|
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
|
||||||
<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,14 +1,13 @@
|
|||||||
/* Theme Milkdown Crepe to the app's design tokens so it follows light/dark.
|
/* Theme Milkdown Crepe to blend into the app's translucent "glass" surfaces.
|
||||||
* Crepe's theme is a palette driven by --crepe-* custom properties; we map
|
* Crepe's frame-dark theme is a grayscale palette driven by --crepe-* custom
|
||||||
* those onto our CSS variables (globals.css) so the editor (and the read-only
|
* properties; we override those to white-on-transparent with a blue accent so
|
||||||
* display, which uses the same engine) inherits the active theme and sits on
|
* the editor (and the read-only display, which uses the same engine) 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. Our tokens resolve against the
|
* unreadable over the content behind it. */
|
||||||
* :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,
|
||||||
@@ -16,30 +15,24 @@
|
|||||||
.milkdown-link-preview,
|
.milkdown-link-preview,
|
||||||
.milkdown-block-handle {
|
.milkdown-block-handle {
|
||||||
--crepe-color-background: transparent;
|
--crepe-color-background: transparent;
|
||||||
--crepe-color-on-background: var(--card-foreground);
|
--crepe-color-on-background: rgb(255 255 255 / 0.92);
|
||||||
/* Surface backs code blocks, tables, and the floating menus — use an opaque
|
/* Surface backs code blocks, tables, and the floating menus — keep it
|
||||||
* themed surface so menus read clearly over content. */
|
* (near-)opaque so menus read clearly over content. */
|
||||||
--crepe-color-surface: var(--popover);
|
--crepe-color-surface: rgb(24 24 28 / 0.96);
|
||||||
--crepe-color-surface-low: var(--muted);
|
--crepe-color-surface-low: rgb(42 42 50 / 0.96);
|
||||||
--crepe-color-on-surface: var(--popover-foreground);
|
--crepe-color-on-surface: #ffffff;
|
||||||
--crepe-color-on-surface-variant: var(--muted-foreground);
|
--crepe-color-on-surface-variant: rgb(255 255 255 / 0.65);
|
||||||
--crepe-color-outline: var(--border);
|
--crepe-color-outline: rgb(255 255 255 / 0.2);
|
||||||
--crepe-color-primary: var(--primary);
|
--crepe-color-primary: #60a5fa;
|
||||||
--crepe-color-secondary: color-mix(in oklch, var(--primary) 22%, transparent);
|
--crepe-color-secondary: rgb(96 165 250 / 0.25);
|
||||||
--crepe-color-on-secondary: var(--foreground);
|
--crepe-color-on-secondary: #ffffff;
|
||||||
--crepe-color-inverse: var(--foreground);
|
--crepe-color-inverse: #ffffff;
|
||||||
--crepe-color-on-inverse: var(--background);
|
--crepe-color-on-inverse: #0b0b0b;
|
||||||
--crepe-color-inline-code: var(--primary);
|
--crepe-color-inline-code: #fca5a5;
|
||||||
--crepe-color-inline-area: var(--muted);
|
--crepe-color-inline-area: rgb(255 255 255 / 0.1);
|
||||||
--crepe-color-error: var(--destructive);
|
--crepe-color-error: #f87171;
|
||||||
--crepe-color-hover: var(--accent);
|
--crepe-color-hover: rgb(255 255 255 / 0.08);
|
||||||
--crepe-color-selected: color-mix(in oklch, var(--primary) 22%, transparent);
|
--crepe-color-selected: rgb(96 165 250 / 0.25);
|
||||||
/* 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;
|
||||||
@@ -64,7 +57,7 @@
|
|||||||
.llink-crepe .milkdown .ProseMirror {
|
.llink-crepe .milkdown .ProseMirror {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
caret-color: var(--foreground);
|
caret-color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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;
|
||||||
@@ -110,19 +103,33 @@
|
|||||||
line-height: 1.375rem;
|
line-height: 1.375rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
color: var(--muted-foreground);
|
color: rgb(255 255 255 / 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.llink-crepe .milkdown .ProseMirror > :first-child {
|
.llink-crepe .milkdown .ProseMirror > :first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Image *links* render through the base commonmark schema (the ImageBlock
|
/* Images come from URLs only (no stable public upload URL), so hide the
|
||||||
* feature is off — see markdown-editor.tsx). Keep them within the content
|
* file uploader — the placeholder then just prompts for a link. */
|
||||||
* column and softly rounded. */
|
.llink-crepe
|
||||||
.llink-crepe .milkdown .ProseMirror img {
|
.milkdown
|
||||||
max-width: 100%;
|
:is(.milkdown-image-block, .milkdown-image-inline)
|
||||||
max-height: 420px;
|
.placeholder
|
||||||
|
.uploader {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Read-only renders the image node view with inert editing chrome — hide it. */
|
||||||
|
.llink-crepe:not(.llink-crepe--fill) .milkdown .milkdown-image-block .operation,
|
||||||
|
.llink-crepe:not(.llink-crepe--fill)
|
||||||
|
.milkdown
|
||||||
|
.milkdown-image-block
|
||||||
|
.image-resize-handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llink-crepe .milkdown .milkdown-image-block img {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Crepe } from '@milkdown/crepe';
|
import { Crepe } from '@milkdown/crepe';
|
||||||
import { editorViewCtx, editorViewOptionsCtx } from '@milkdown/kit/core';
|
import { editorViewCtx } 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
|
||||||
@@ -58,18 +57,23 @@ 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,
|
||||||
// Off on purpose: file uploads aren't supported, so this feature's
|
[Crepe.Feature.ImageBlock]: true,
|
||||||
// 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')),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,21 +85,6 @@ 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,5 +1,4 @@
|
|||||||
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';
|
||||||
@@ -8,7 +7,6 @@ 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';
|
||||||
@@ -36,41 +34,16 @@ interface RecordingOverlayProps {
|
|||||||
objectFit?: 'cover' | 'contain';
|
objectFit?: 'cover' | 'contain';
|
||||||
}
|
}
|
||||||
|
|
||||||
function useRecordingCountdown(active: boolean) {
|
function RecordingTimer() {
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return;
|
|
||||||
|
|
||||||
let toastSent = false;
|
|
||||||
const start = Date.now();
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
const seconds = Math.floor((Date.now() - start) / 1000);
|
setElapsed((prev) => prev + 1);
|
||||||
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);
|
}, 1000);
|
||||||
return () => {
|
return () => clearInterval(interval);
|
||||||
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')}`;
|
||||||
@@ -78,14 +51,7 @@ 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
|
<span className="font-mono text-sm text-white/80">{display}</span>
|
||||||
className={cn(
|
|
||||||
'font-mono text-sm text-white/80',
|
|
||||||
isWarning && 'text-red-400',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{display}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -171,31 +137,14 @@ 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(
|
||||||
'bg-scrim/90 absolute inset-0 z-50 flex flex-col items-center justify-center',
|
'absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90',
|
||||||
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">
|
||||||
@@ -230,13 +179,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="from-scrim/60 pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t to-transparent" />
|
<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" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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 elapsed={elapsed} isWarning={isWarning} />
|
<RecordingTimer />
|
||||||
) : 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,38 +63,37 @@ export function TaskComposeStep({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-background/95 absolute inset-0 z-50 flex flex-col pt-16 backdrop-blur-sm"
|
className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16"
|
||||||
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="text-muted-foreground mb-1 text-xs">Task</Label>
|
<Label className="mb-1 text-xs text-white/50">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="text-muted-foreground mb-1 text-xs">Notes</Label>
|
<Label className="mb-1 text-xs text-white/50">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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-muted-foreground mb-1 text-xs">
|
<Label className="mb-1 text-xs text-white/50">Assign to</Label>
|
||||||
Assign to
|
|
||||||
</Label>
|
|
||||||
<Select value={assignedTo} onValueChange={setAssignedTo}>
|
<Select value={assignedTo} onValueChange={setAssignedTo}>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full border-white/10 bg-white/5 text-white">
|
||||||
<SelectValue placeholder="Unassigned" />
|
<SelectValue placeholder="Unassigned" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -109,7 +108,7 @@ export function TaskComposeStep({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-muted-foreground absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm">
|
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
|
||||||
<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="text-muted-foreground absolute bottom-4 flex items-center gap-4 text-sm">
|
<div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50">
|
||||||
<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(
|
||||||
'bg-background/95 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm',
|
'absolute inset-0 z-50 flex items-center justify-center bg-black/90',
|
||||||
isDragging && 'ring-primary/40 ring-2 ring-inset',
|
isDragging && 'ring-2 ring-inset ring-white/30',
|
||||||
)}
|
)}
|
||||||
{...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(
|
||||||
'text-foreground placeholder-muted-foreground w-full resize-none border-none bg-transparent p-8 text-center outline-none',
|
'w-full resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 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(
|
||||||
'bg-background/95 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm',
|
'absolute inset-0 z-50 flex items-center justify-center bg-black/90',
|
||||||
isDragging && 'ring-primary/40 ring-2 ring-inset',
|
isDragging && 'ring-2 ring-inset ring-white/30',
|
||||||
)}
|
)}
|
||||||
{...dropZoneProps}
|
{...dropZoneProps}
|
||||||
onKeyDownCapture={handleKeyDown}
|
onKeyDownCapture={handleKeyDown}
|
||||||
>
|
>
|
||||||
<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">
|
<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">
|
||||||
{/* 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="border-border shrink-0 border-t px-5 py-3">
|
<div className="shrink-0 border-t border-white/10 px-5 py-3">
|
||||||
{strip}
|
{strip}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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="text-muted-foreground flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm text-white/50">
|
||||||
<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,16 +38,14 @@ export function DeletedParticleView({
|
|||||||
}, [paused, onEnded, particle.id]);
|
}, [paused, onEnded, particle.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center px-8">
|
<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 flex-col items-center gap-3 text-center">
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
<Trash2 className="text-muted-foreground size-6" />
|
<Trash2 className="text-white/40 size-6" />
|
||||||
<p className="text-foreground text-base font-medium">
|
<p className="text-white/70 text-base font-medium">
|
||||||
This particle was deleted
|
This particle was deleted
|
||||||
</p>
|
</p>
|
||||||
{deleter && (
|
{deleter && (
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-white/40 text-xs">by {deleter.displayName}</p>
|
||||||
by {deleter.displayName}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export const MediaParticleView = forwardRef<
|
|||||||
|
|
||||||
if (isAudio) {
|
if (isAudio) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-scrim/90 relative flex h-full w-full items-center justify-center">
|
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
|
||||||
<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="bg-black h-full w-full object-contain"
|
className="h-full w-full object-contain bg-black"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{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" />;
|
return <Skeleton className="h-20 w-20 shrink-0 rounded-lg bg-white/10" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownload = (e: React.MouseEvent) => {
|
const handleDownload = (e: React.MouseEvent) => {
|
||||||
@@ -78,7 +78,7 @@ function ImageAttachment({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onPreview();
|
onPreview();
|
||||||
}}
|
}}
|
||||||
className="bg-muted group relative block h-30 w-40 shrink-0 overflow-hidden rounded-lg"
|
className="group relative block h-30 w-40 shrink-0 overflow-hidden rounded-lg bg-white/10"
|
||||||
>
|
>
|
||||||
<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="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"
|
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"
|
||||||
>
|
>
|
||||||
<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="bg-muted hover:bg-accent flex shrink-0 cursor-pointer flex-col gap-1.5 rounded-lg px-3 py-2 transition-colors"
|
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"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileIcon className="text-muted-foreground size-4 shrink-0" />
|
<FileIcon className="size-4 shrink-0 text-white/60" />
|
||||||
<span className="text-foreground max-w-[10rem] truncate text-xs font-medium">
|
<span className="max-w-[10rem] truncate text-xs font-medium text-white/90">
|
||||||
{particle.properties.filename}
|
{particle.properties.filename}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground text-[10px]">
|
<span className="text-[10px] text-white/40">
|
||||||
{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-muted-foreground"
|
className="text-white/70 hover:bg-white/10 hover:text-white"
|
||||||
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-muted-foreground"
|
className="text-white/70 hover:bg-white/10 hover:text-white"
|
||||||
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="bg-muted hover:bg-accent flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left transition-colors"
|
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"
|
||||||
>
|
>
|
||||||
{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="text-muted-foreground size-4 shrink-0" />
|
<ImageIcon className="size-4 shrink-0 text-white/50" />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<FileIcon className="text-muted-foreground size-4 shrink-0" />
|
<FileIcon className="size-4 shrink-0 text-white/50" />
|
||||||
)}
|
)}
|
||||||
<span className="text-foreground min-w-0 truncate text-xs">
|
<span className="min-w-0 truncate text-xs text-white/80">
|
||||||
{particle.properties.filename}
|
{particle.properties.filename}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground shrink-0 text-[10px]">
|
<span className="shrink-0 text-[10px] text-white/40">
|
||||||
{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="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded font-mono text-xs">
|
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground">
|
||||||
{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-600 dark:text-red-400" />
|
<Headphones className="size-3 text-red-400" />
|
||||||
<span className="text-[10px] font-medium text-red-600 dark:text-red-400">
|
<span className="text-[10px] font-medium text-red-400">
|
||||||
{huddleCount}
|
{huddleCount}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -298,7 +298,7 @@ const FolderRow = memo(function FolderRow({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{shortcutKey && (
|
{shortcutKey && (
|
||||||
<kbd className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded font-mono text-xs">
|
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground">
|
||||||
{shortcutKey}
|
{shortcutKey}
|
||||||
</kbd>
|
</kbd>
|
||||||
)}
|
)}
|
||||||
@@ -356,7 +356,7 @@ const LeafRow = memo(function LeafRow({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{shortcutKey && (
|
{shortcutKey && (
|
||||||
<kbd className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded font-mono text-xs">
|
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground">
|
||||||
{shortcutKey}
|
{shortcutKey}
|
||||||
</kbd>
|
</kbd>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ function MediaPreview({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-muted text-muted-foreground flex h-full w-full flex-col items-center justify-center gap-2">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-black/90">
|
||||||
<Mic className="h-8 w-8" />
|
<Mic className="h-8 w-8 text-white/60" />
|
||||||
<span className="font-mono text-xs">{durationLabel}</span>
|
<span className="font-mono text-xs text-white/50">{durationLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -103,8 +103,8 @@ function VideoThumbnail({
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-muted flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center bg-black/80">
|
||||||
<Video className="text-muted-foreground h-8 w-8" />
|
<Video className="h-8 w-8 text-white/40" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ function VideoThumbnail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-muted relative h-full w-full">
|
<div className="relative h-full w-full bg-black">
|
||||||
<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="bg-scrim/70 absolute bottom-1.5 right-1.5 rounded px-1.5 py-0.5 font-mono text-[10px] text-white">
|
<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">
|
||||||
{duration}
|
{duration}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ function LeafParticleView({
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background min-h-0 flex-1 [--stream-safe-top:2rem] [--stream-safe-bottom:2rem]">
|
<div className="min-h-0 flex-1 bg-black text-white [--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="bg-muted-foreground/30 absolute inset-x-0 bottom-0 h-[3px] transition-all group-hover:h-1.5" />
|
<div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/30 transition-all group-hover:h-1.5" />
|
||||||
{/* Fill */}
|
{/* Fill */}
|
||||||
<div
|
<div
|
||||||
className="bg-foreground absolute bottom-0 left-0 h-[3px] transition-all group-hover:h-1.5"
|
className="absolute left-0 bottom-0 h-[3px] bg-white/90 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="text-muted-foreground pointer-events-none pt-1 text-center text-[10px] font-medium tabular-nums tracking-wide">
|
<div className="pointer-events-none pt-1 text-center text-[10px] font-medium tabular-nums tracking-wide text-white/40">
|
||||||
{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="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" />
|
<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" />
|
||||||
</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-background'
|
: 'ring-1 ring-black/50'
|
||||||
}
|
}
|
||||||
avatarObjectId={human.avatarObjectId}
|
avatarObjectId={human.avatarObjectId}
|
||||||
initials={human.emailPrefix.slice(0, 2).toUpperCase()}
|
initials={human.emailPrefix.slice(0, 2).toUpperCase()}
|
||||||
@@ -178,9 +178,7 @@ function SegmentPresenceAvatars({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
{overflow > 0 && (
|
{overflow > 0 && (
|
||||||
<span className="text-muted-foreground pl-1 text-[10px]">
|
<span className="text-[10px] text-white/70 pl-1">+{overflow}</span>
|
||||||
+{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-primary/15 text-foreground ring-1 ring-primary/40'
|
? 'bg-white/20 ring-1 ring-white/40'
|
||||||
: 'bg-card/70 text-foreground border border-border hover:bg-accent',
|
: 'bg-black/40 hover:bg-black/50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-sm">{emoji}</span>
|
<span className="text-sm">{emoji}</span>
|
||||||
<span className="text-muted-foreground">{reactors.length}</span>
|
<span className="text-white/80">{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-primary/15 text-foreground ring-1 ring-primary/40'
|
? 'bg-white/20 ring-1 ring-white/40'
|
||||||
: 'bg-card/70 text-foreground border border-border hover:bg-accent',
|
: 'bg-black/40 hover:bg-black/50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<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-muted text-muted-foreground text-[9px] font-medium"
|
fallbackClassName="bg-white/15 text-[9px] font-medium text-white"
|
||||||
/>
|
/>
|
||||||
<span className="truncate text-foreground">{text}</span>
|
<span className="truncate text-white/90">{text}</span>
|
||||||
{reactors.length > 1 && (
|
{reactors.length > 1 && (
|
||||||
<span className="text-muted-foreground shrink-0">
|
<span className="shrink-0 text-white/60">
|
||||||
{reactors.length}
|
{reactors.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -154,7 +154,7 @@ export function ReactionBar({
|
|||||||
|
|
||||||
{/* Picker / actions */}
|
{/* Picker / actions */}
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<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">
|
<div className="flex flex-col items-center gap-0.5 rounded-full bg-black/40 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="hover:bg-accent rounded-full px-0.5 py-1 text-sm transition-colors"
|
className="rounded-full px-0.5 py-1 text-sm transition-colors hover:bg-white/15"
|
||||||
>
|
>
|
||||||
{emoji}
|
{emoji}
|
||||||
</button>
|
</button>
|
||||||
@@ -175,9 +175,9 @@ export function ReactionBar({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
}}
|
}}
|
||||||
className="hover:bg-accent flex size-5 items-center justify-center rounded-full transition-colors"
|
className="flex size-5 items-center justify-center rounded-full transition-colors hover:bg-white/15"
|
||||||
>
|
>
|
||||||
<X className="text-muted-foreground size-3" />
|
<X className="size-3 text-white/60" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -189,14 +189,14 @@ export function ReactionBar({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onOpenTextReaction();
|
onOpenTextReaction();
|
||||||
}}
|
}}
|
||||||
className="bg-card/70 border-border hover:bg-accent flex size-6 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
|
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
|
||||||
>
|
>
|
||||||
<Type className="text-muted-foreground size-3" />
|
<Type className="size-3 text-white/60" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="left" className="text-xs">
|
<TooltipContent side="left" className="text-xs">
|
||||||
Quick reply{' '}
|
Quick reply{' '}
|
||||||
<kbd className="bg-muted ml-1 rounded px-1 font-mono text-[10px]">
|
<kbd className="ml-1 rounded bg-white/10 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="bg-card/70 border-border hover:bg-accent flex size-6 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
|
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
|
||||||
>
|
>
|
||||||
<Plus className="text-muted-foreground size-3" />
|
<Plus className="size-3 text-white/60" />
|
||||||
</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-scrim/60 backdrop-blur-sm"
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
<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="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="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold">Rename stream</h2>
|
<h2 className="text-sm font-semibold text-white/70">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-muted-foreground text-xs"
|
className="text-xs text-white/30"
|
||||||
>
|
>
|
||||||
to close
|
to close
|
||||||
</KeyHint>
|
</KeyHint>
|
||||||
@@ -85,6 +85,7 @@ 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="bg-card/70 text-muted-foreground border-border rounded-full border px-2 text-xs backdrop-blur-sm">
|
<span className="rounded-full bg-black/30 px-2 text-xs text-white/70 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="text-muted-foreground flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm text-white/50">
|
||||||
{showEscape && (
|
{showEscape && (
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys="Esc"
|
keys="Esc"
|
||||||
|
|||||||
@@ -39,17 +39,17 @@ export function StreamListSidebar({
|
|||||||
}, [currentIndex]);
|
}, [currentIndex]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="bg-sidebar text-sidebar-foreground border-border flex w-60 shrink-0 flex-col overflow-hidden border-l">
|
<aside className="dark flex max-w-60 shrink-0 flex-col border-l border-white/10 bg-zinc-950">
|
||||||
<div className="border-border flex shrink-0 items-center gap-2 border-b px-4 py-3">
|
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-4 py-3">
|
||||||
<List className="text-muted-foreground size-3.5" />
|
<List className="size-3.5 text-white/40" />
|
||||||
<span className="mr-auto truncate text-sm font-medium">
|
<span className="truncate text-sm font-medium text-white/90 mr-auto">
|
||||||
{items.length} messages
|
{items.length} messages
|
||||||
</span>
|
</span>
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys="L"
|
keys="L"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
title="Hide list (or press L)"
|
title="Hide list (or press L)"
|
||||||
className="text-muted-foreground text-xs"
|
className="text-xs text-white/40"
|
||||||
>
|
>
|
||||||
to close
|
to close
|
||||||
</KeyHint>
|
</KeyHint>
|
||||||
@@ -72,7 +72,7 @@ export function StreamListSidebar({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<p className="text-muted-foreground px-2 py-8 text-center text-sm">
|
<p className="px-2 py-8 text-center text-sm text-white/40">
|
||||||
No particles in this stream yet.
|
No particles in this stream yet.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -105,8 +105,8 @@ function ChatRow({
|
|||||||
if (e.key === 'Enter') onClick();
|
if (e.key === 'Enter') onClick();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors',
|
'flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors',
|
||||||
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
|
isSelected ? 'bg-white/10' : 'hover:bg-white/5',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<HumanAvatar
|
<HumanAvatar
|
||||||
@@ -114,14 +114,14 @@ function ChatRow({
|
|||||||
className="mt-0.5 shrink-0"
|
className="mt-0.5 shrink-0"
|
||||||
initials={sender.initials}
|
initials={sender.initials}
|
||||||
avatarObjectId={sender.avatarObjectId}
|
avatarObjectId={sender.avatarObjectId}
|
||||||
fallbackClassName="bg-muted text-muted-foreground text-[10px] font-medium"
|
fallbackClassName="bg-white/10 text-white/80 text-[10px] font-medium"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1 overflow-hidden">
|
<div className="min-w-0 flex-1">
|
||||||
<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">
|
<span className="truncate text-xs font-semibold text-white/90">
|
||||||
{sender.displayName}
|
{sender.displayName}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground shrink-0 text-[10px]">
|
<span className="shrink-0 text-[10px] text-white/35">
|
||||||
<RelativeTimestamp date={particle.created_at} />
|
<RelativeTimestamp date={particle.created_at} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,16 +134,14 @@ function ChatRow({
|
|||||||
function ChatRowContent({ particle }: { particle: Particle }) {
|
function ChatRowContent({ particle }: { particle: Particle }) {
|
||||||
if (isParticleDeleted(particle)) {
|
if (isParticleDeleted(particle)) {
|
||||||
return (
|
return (
|
||||||
<p className="text-muted-foreground text-xs italic">
|
<p className="text-xs italic text-white/35">This particle was deleted</p>
|
||||||
This particle was deleted
|
|
||||||
</p>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
return (
|
return (
|
||||||
<p className="text-muted-foreground line-clamp-2 whitespace-pre-line text-xs leading-relaxed [overflow-wrap:anywhere]">
|
<p className="line-clamp-2 text-xs leading-relaxed whitespace-pre-line text-white/70">
|
||||||
{particle.properties.content}
|
{particle.properties.content}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
@@ -168,13 +166,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="text-muted-foreground flex items-center gap-1.5 text-xs">
|
<span className="flex items-center gap-1.5 text-xs text-white/70">
|
||||||
<Icon className="text-muted-foreground size-3.5 shrink-0" />
|
<Icon className="size-3.5 shrink-0 text-white/50" />
|
||||||
{label}
|
{label}
|
||||||
{duration}
|
{duration}
|
||||||
</span>
|
</span>
|
||||||
{transcript && (
|
{transcript && (
|
||||||
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
<p className="line-clamp-2 text-xs leading-relaxed text-white/45">
|
||||||
{transcript}
|
{transcript}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -183,8 +181,8 @@ function ChatRowContent({ particle }: { particle: Particle }) {
|
|||||||
}
|
}
|
||||||
case 'file':
|
case 'file':
|
||||||
return (
|
return (
|
||||||
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
<span className="flex items-center gap-1.5 text-xs text-white/70">
|
||||||
<FileText className="text-muted-foreground size-3.5 shrink-0" />
|
<FileText className="size-3.5 shrink-0 text-white/50" />
|
||||||
<span className="truncate">{particle.properties.filename}</span>
|
<span className="truncate">{particle.properties.filename}</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -193,26 +191,21 @@ 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="text-muted-foreground flex items-center gap-1.5 text-xs">
|
<span className="flex items-center gap-1.5 text-xs text-white/70">
|
||||||
<CircleCheck
|
<CircleCheck
|
||||||
className={cn(
|
className={cn(
|
||||||
'size-3.5 shrink-0',
|
'size-3.5 shrink-0',
|
||||||
done
|
done ? 'text-emerald-400' : 'text-white/50',
|
||||||
? 'text-emerald-600 dark:text-emerald-400'
|
|
||||||
: 'text-muted-foreground',
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn('truncate', done && 'text-white/40 line-through')}
|
||||||
'truncate',
|
|
||||||
done && 'text-muted-foreground line-through',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{checklist.length > 0 && (
|
{checklist.length > 0 && (
|
||||||
<span className="text-muted-foreground pl-5 text-[10px]">
|
<span className="pl-5 text-[10px] text-white/40">
|
||||||
{doneCount} / {checklist.length} subtasks
|
{doneCount} / {checklist.length} subtasks
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -221,11 +214,11 @@ function ChatRowContent({ particle }: { particle: Particle }) {
|
|||||||
}
|
}
|
||||||
case 'paper':
|
case 'paper':
|
||||||
return (
|
return (
|
||||||
<p className="text-muted-foreground truncate text-xs">
|
<p className="truncate text-xs text-white/70">
|
||||||
{particle.properties.title}
|
{particle.properties.title}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return <p className="text-muted-foreground text-xs">{particle.type}</p>;
|
return <p className="text-xs text-white/45">{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="bg-scrim/60 absolute inset-0 backdrop-blur-sm"
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
<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">
|
<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">
|
||||||
{/* 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">Members</h2>
|
<h2 className="text-sm font-semibold text-white/70">Members</h2>
|
||||||
<KeyHint
|
<KeyHint
|
||||||
keys="Esc"
|
keys="Esc"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title="Close (or press Esc)"
|
title="Close (or press Esc)"
|
||||||
className="text-muted-foreground text-xs"
|
className="text-xs text-white/30"
|
||||||
>
|
>
|
||||||
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="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
|
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||||
Visibility
|
Visibility
|
||||||
</h3>
|
</h3>
|
||||||
{isCreator ? (
|
{isCreator ? (
|
||||||
<div className="bg-muted grid grid-cols-2 gap-1 rounded-lg p-1">
|
<div className="grid grid-cols-2 gap-1 rounded-lg bg-white/5 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="text-foreground flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm text-white/70">
|
||||||
{visibility.mode === 'network' ? (
|
{visibility.mode === 'network' ? (
|
||||||
<>
|
<>
|
||||||
<Globe className="text-muted-foreground size-3.5" />
|
<Globe className="size-3.5 text-white/40" />
|
||||||
<span>Everyone in {network?.name ?? 'network'}</span>
|
<span>Everyone in {network?.name ?? 'network'}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Lock className="text-muted-foreground size-3.5" />
|
<Lock className="size-3.5 text-white/40" />
|
||||||
<span>{memberIds.length} specific people</span>
|
<span>{memberIds.length} specific people</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -153,11 +153,9 @@ 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="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
|
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||||
{visibility.mode === 'network' ? 'Has access' : 'People'}{' '}
|
{visibility.mode === 'network' ? 'Has access' : 'People'}{' '}
|
||||||
<span className="text-muted-foreground ml-1">
|
<span className="ml-1 text-white/20">{memberIds.length}</span>
|
||||||
{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">
|
||||||
@@ -169,7 +167,7 @@ export function StreamMembersOverlay({
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={id}
|
key={id}
|
||||||
className="text-foreground group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm"
|
className="group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm text-white/70"
|
||||||
>
|
>
|
||||||
<HumanAvatar
|
<HumanAvatar
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -180,13 +178,13 @@ export function StreamMembersOverlay({
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 truncate',
|
'flex-1 truncate',
|
||||||
!display.exists && 'text-muted-foreground italic',
|
!display.exists && 'italic text-white/40',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{display.displayName}
|
{display.displayName}
|
||||||
</span>
|
</span>
|
||||||
{isCreatorRow && (
|
{isCreatorRow && (
|
||||||
<span className="text-muted-foreground text-[10px] uppercase tracking-wider">
|
<span className="text-[10px] uppercase tracking-wider text-white/30">
|
||||||
Creator
|
Creator
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -194,7 +192,7 @@ export function StreamMembersOverlay({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeMember(id)}
|
onClick={() => removeMember(id)}
|
||||||
className="text-muted-foreground hover:bg-accent hover:text-foreground rounded p-1 opacity-0 transition-opacity group-hover:opacity-100"
|
className="rounded p-1 text-white/30 opacity-0 transition-opacity hover:bg-white/10 hover:text-white/70 group-hover:opacity-100"
|
||||||
aria-label={`Remove ${display.displayName}`}
|
aria-label={`Remove ${display.displayName}`}
|
||||||
>
|
>
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
@@ -211,8 +209,8 @@ export function StreamMembersOverlay({
|
|||||||
{isCreator &&
|
{isCreator &&
|
||||||
visibility.mode === 'custom' &&
|
visibility.mode === 'custom' &&
|
||||||
availableToAdd.length > 0 && (
|
availableToAdd.length > 0 && (
|
||||||
<section className="border-border mt-4 border-t pt-4">
|
<section className="mt-4 border-t border-white/5 pt-4">
|
||||||
<h3 className="text-muted-foreground mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest">
|
<h3 className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-white/30">
|
||||||
<UserPlus className="size-3" />
|
<UserPlus className="size-3" />
|
||||||
Add people
|
Add people
|
||||||
</h3>
|
</h3>
|
||||||
@@ -224,7 +222,7 @@ export function StreamMembersOverlay({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => addMember(human.id)}
|
onClick={() => addMember(human.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'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',
|
'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',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<HumanAvatar
|
<HumanAvatar
|
||||||
@@ -236,7 +234,7 @@ export function StreamMembersOverlay({
|
|||||||
<span className="flex-1 truncate">
|
<span className="flex-1 truncate">
|
||||||
{human.email_prefix}
|
{human.email_prefix}
|
||||||
</span>
|
</span>
|
||||||
<UserPlus className="text-muted-foreground size-3.5" />
|
<UserPlus className="size-3.5 text-white/30" />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -248,7 +246,7 @@ export function StreamMembersOverlay({
|
|||||||
{isCreator &&
|
{isCreator &&
|
||||||
visibility.mode === 'custom' &&
|
visibility.mode === 'custom' &&
|
||||||
availableToAdd.length === 0 && (
|
availableToAdd.length === 0 && (
|
||||||
<p className="text-muted-foreground mt-4 text-center text-xs">
|
<p className="mt-4 text-center text-xs text-white/30">
|
||||||
<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>
|
||||||
@@ -277,8 +275,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-background text-foreground shadow-sm'
|
? 'bg-white/10 text-white/90'
|
||||||
: 'text-muted-foreground hover:text-foreground',
|
: 'text-white/50 hover:text-white/80',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{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 bg-card/70 border-border mx-auto rounded-full border px-3 py-1 backdrop-blur-sm">
|
<Breadcrumb className="no-drag rounded-full bg-black/30 backdrop-blur-sm px-3 py-1 mx-auto">
|
||||||
<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-700 dark:text-red-200"
|
fallbackClassName="bg-red-500/30 text-[8px] text-red-200"
|
||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{display.email}</TooltipContent>
|
<TooltipContent>{display.email}</TooltipContent>
|
||||||
@@ -147,14 +147,12 @@ export function TopBar({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</AvatarGroup>
|
</AvatarGroup>
|
||||||
<span className="text-xs font-medium text-red-600 dark:text-red-200">
|
<span className="text-xs font-medium text-red-200">Join</span>
|
||||||
Join
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isStreamOpen(streamParticle) && (
|
{!isStreamOpen(streamParticle) && (
|
||||||
<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">
|
<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">
|
||||||
<CircleCheckBig className="size-3" />
|
<CircleCheckBig className="size-3" />
|
||||||
Closed
|
Closed
|
||||||
</span>
|
</span>
|
||||||
@@ -282,16 +280,16 @@ function MembersIndicator({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{isNetworkWide ? (
|
{isNetworkWide ? (
|
||||||
<>
|
<>
|
||||||
<Globe className="text-muted-foreground size-3" />
|
<Globe className="size-3 text-white/50" />
|
||||||
<span>Everyone</span>
|
<span>Everyone</span>
|
||||||
</>
|
</>
|
||||||
) : isPrivate ? (
|
) : isPrivate ? (
|
||||||
<>
|
<>
|
||||||
<Lock className="text-muted-foreground size-3" />
|
<Lock className="size-3 text-white/50" />
|
||||||
<span>Private</span>
|
<span>Private</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -308,7 +306,7 @@ function MembersIndicator({
|
|||||||
))}
|
))}
|
||||||
</AvatarGroup>
|
</AvatarGroup>
|
||||||
{overflow > 0 && (
|
{overflow > 0 && (
|
||||||
<span className="text-muted-foreground">+{overflow}</span>
|
<span className="text-white/50">+{overflow}</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
|
|
||||||
if (children.length === 0) {
|
if (children.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex h-full flex-col items-center justify-center gap-4">
|
<div className="flex h-full flex-col items-center justify-center gap-4 bg-black text-white">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
No particles in this stream yet
|
No particles in this stream yet
|
||||||
</p>
|
</p>
|
||||||
@@ -449,7 +449,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
paused={paused}
|
paused={paused}
|
||||||
onEnded={handleParticleEnded}
|
onEnded={handleParticleEnded}
|
||||||
onProgress={setProgress}
|
onProgress={setProgress}
|
||||||
immersive
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
@@ -460,15 +459,15 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex h-screen overflow-hidden">
|
<div className="flex h-screen overflow-hidden bg-black">
|
||||||
{/* Stream chrome — immersive playback column */}
|
{/* Stream chrome — immersive playback column */}
|
||||||
<div
|
<div
|
||||||
className="bg-background relative flex min-w-0 flex-1 flex-col overflow-hidden [--stream-safe-top:4rem] [--stream-safe-bottom:5rem]"
|
className="relative flex min-w-0 flex-1 flex-col overflow-hidden bg-black text-white [--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="from-background/80 pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b to-transparent" />
|
<div className="pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b from-black/60 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">
|
||||||
@@ -488,7 +487,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="bg-card/70 text-foreground border-border rounded-full border px-2.5 py-1 text-xs font-medium backdrop-blur-sm">
|
<div className="rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm">
|
||||||
1.5x
|
1.5x
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -497,7 +496,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="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"
|
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"
|
||||||
>
|
>
|
||||||
<Play className="size-3 fill-current" />
|
<Play className="size-3 fill-current" />
|
||||||
Paused
|
Paused
|
||||||
@@ -541,7 +540,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Bottom gradient safe zone for keyboard hints */}
|
{/* Bottom gradient safe zone for keyboard hints */}
|
||||||
<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" />
|
<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" />
|
||||||
|
|
||||||
{/* BottomBar — pinned visible while browsing, mouse-activity in player */}
|
{/* BottomBar — pinned visible while browsing, mouse-activity in player */}
|
||||||
<BottomBar
|
<BottomBar
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ 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' }>;
|
||||||
|
|
||||||
@@ -39,28 +37,11 @@ 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,
|
||||||
@@ -75,7 +56,6 @@ 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);
|
||||||
@@ -94,33 +74,6 @@ 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,
|
||||||
@@ -212,21 +165,9 @@ 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 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 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)]">
|
||||||
{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={cn(
|
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"
|
||||||
'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);
|
||||||
@@ -236,7 +177,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-muted-foreground/40 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
|
className="mt-1.5 size-5 rounded-full border-white/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
|
||||||
@@ -246,8 +187,8 @@ export function TaskParticleView({
|
|||||||
onBlur={titleField.onBlur}
|
onBlur={titleField.onBlur}
|
||||||
placeholder="Task title"
|
placeholder="Task title"
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-foreground placeholder:text-muted-foreground w-full bg-transparent text-2xl font-semibold outline-none',
|
'w-full bg-transparent text-2xl font-semibold text-white outline-none placeholder:text-white/30',
|
||||||
done && 'text-muted-foreground line-through',
|
done && 'text-white/50 line-through',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -258,12 +199,12 @@ export function TaskParticleView({
|
|||||||
onFocus={notesField.onFocus}
|
onFocus={notesField.onFocus}
|
||||||
onBlur={notesField.onBlur}
|
onBlur={notesField.onBlur}
|
||||||
placeholder="Add notes…"
|
placeholder="Add notes…"
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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-muted-foreground text-xs">
|
<span className="text-xs text-white/40">
|
||||||
{doneCount} / {checklist.length} done
|
{doneCount} / {checklist.length} done
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -286,7 +227,10 @@ export function TaskParticleView({
|
|||||||
value={assigned_to ?? UNASSIGNED}
|
value={assigned_to ?? UNASSIGNED}
|
||||||
onValueChange={handleAssign}
|
onValueChange={handleAssign}
|
||||||
>
|
>
|
||||||
<SelectTrigger size="sm" className="w-fit gap-2">
|
<SelectTrigger
|
||||||
|
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"
|
||||||
@@ -307,16 +251,6 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -342,7 +276,7 @@ function ChecklistItemRow({
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={item.done}
|
checked={item.done}
|
||||||
onCheckedChange={(checked) => onToggle(checked === true)}
|
onCheckedChange={(checked) => onToggle(checked === true)}
|
||||||
className="border-muted-foreground/40 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
|
className="border-white/30 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'
|
||||||
}
|
}
|
||||||
@@ -354,14 +288,14 @@ function ChecklistItemRow({
|
|||||||
onBlur={textField.onBlur}
|
onBlur={textField.onBlur}
|
||||||
placeholder="Subtask"
|
placeholder="Subtask"
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-foreground placeholder:text-muted-foreground w-full bg-transparent text-sm outline-none',
|
'w-full bg-transparent text-sm text-white/90 outline-none placeholder:text-white/30',
|
||||||
item.done && 'text-muted-foreground line-through',
|
item.done && 'text-white/40 line-through',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
className="text-muted-foreground hover:text-foreground opacity-0 transition-opacity group-hover/item:opacity-100"
|
className="text-white/30 opacity-0 transition-opacity hover:text-white/70 group-hover/item:opacity-100"
|
||||||
aria-label="Remove subtask"
|
aria-label="Remove subtask"
|
||||||
>
|
>
|
||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
@@ -382,7 +316,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="text-muted-foreground size-4" />
|
<Plus className="size-4 text-white/30" />
|
||||||
<input
|
<input
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
@@ -394,7 +328,7 @@ function AddChecklistItemRow({ onAdd }: { onAdd: (text: string) => void }) {
|
|||||||
}}
|
}}
|
||||||
onBlur={submit}
|
onBlur={submit}
|
||||||
placeholder="Add subtask…"
|
placeholder="Add subtask…"
|
||||||
className="text-foreground placeholder:text-muted-foreground w-full bg-transparent text-sm outline-none"
|
className="w-full bg-transparent text-sm text-white/70 outline-none placeholder:text-white/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function TextParticleView({
|
|||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}}
|
}}
|
||||||
title="Edit"
|
title="Edit"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<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-muted-foreground text-xs">
|
<span className="text-xs text-white/40">
|
||||||
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 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 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)]">
|
||||||
<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 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 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)]">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-foreground max-w-2xl cursor-text select-text break-words text-center leading-relaxed',
|
'max-w-2xl break-words text-center leading-relaxed text-white select-text cursor-text',
|
||||||
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 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 bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
||||||
<div className="scrollbar-card 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)]">
|
<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">
|
||||||
<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="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"
|
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"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -74,14 +74,12 @@ export function TextReactionInput({
|
|||||||
}}
|
}}
|
||||||
placeholder="Quick reply…"
|
placeholder="Quick reply…"
|
||||||
maxLength={MAX_LENGTH}
|
maxLength={MAX_LENGTH}
|
||||||
className="text-foreground placeholder:text-muted-foreground w-24 bg-transparent text-sm outline-none"
|
className="w-24 bg-transparent text-sm text-white outline-none placeholder:text-white/40"
|
||||||
/>
|
/>
|
||||||
<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
|
remaining <= 8 ? 'text-amber-300/80' : 'text-white/30',
|
||||||
? 'text-amber-600 dark:text-amber-400'
|
|
||||||
: 'text-muted-foreground',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{remaining}
|
{remaining}
|
||||||
@@ -93,8 +91,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-primary text-primary-foreground hover:bg-primary/90'
|
? 'bg-white/20 text-white hover:bg-white/30'
|
||||||
: 'text-muted-foreground',
|
: 'text-white/30',
|
||||||
)}
|
)}
|
||||||
aria-label="Send reaction"
|
aria-label="Send reaction"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -12,16 +12,11 @@ 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';
|
||||||
@@ -29,7 +24,6 @@ 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';
|
||||||
@@ -86,50 +80,6 @@ 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);
|
||||||
@@ -200,7 +150,7 @@ export default function SettingsPage() {
|
|||||||
initials={initials}
|
initials={initials}
|
||||||
fallbackClassName="bg-primary/10 text-primary text-xl font-medium"
|
fallbackClassName="bg-primary/10 text-primary text-xl font-medium"
|
||||||
/>
|
/>
|
||||||
<span className="bg-scrim/40 absolute inset-0 flex items-center justify-center rounded-full opacity-0 transition-opacity group-hover:opacity-100" />
|
<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-primary text-primary-foreground ring-background absolute bottom-0 right-0 flex size-5 items-center justify-center rounded-full ring-2">
|
<span className="bg-primary text-primary-foreground ring-background absolute bottom-0 right-0 flex size-5 items-center justify-center rounded-full ring-2">
|
||||||
<Camera className="size-2.5" />
|
<Camera className="size-2.5" />
|
||||||
</span>
|
</span>
|
||||||
@@ -215,12 +165,6 @@ 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,5 +1,4 @@
|
|||||||
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;
|
||||||
@@ -49,20 +48,15 @@ export function useFileInput({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
|
||||||
// Capture phase so file pastes always become attachments — the markdown
|
// Capture phase so file pastes always become attachments — ProseMirror
|
||||||
// editor would otherwise inline a pasted image as an ephemeral blob: URL.
|
// would otherwise inline pasted images as ephemeral blob: URLs. Mixed
|
||||||
|
// clipboards (e.g. Excel/Word ship an image rendition alongside the text)
|
||||||
|
// must still paste as text, so only file-only pastes are intercepted.
|
||||||
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);
|
||||||
// Actual text means "paste as text", even when the clipboard also ships
|
if (files.length === 0 || data.types.includes('text/plain')) return;
|
||||||
// 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);
|
||||||
@@ -111,8 +105,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,6 @@ 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 = '[email protected]';
|
export const SUPPORT_EMAIL = '[email protected]';
|
||||||
|
|
||||||
export const PRIVACY_URL = 'https://flowylabs.ai/llink/privacy';
|
export const PRIVACY_URL = 'https://flowylabs.ai/llink/privacy';
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,7 +1,6 @@
|
|||||||
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();
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
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 });
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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,92 +47,75 @@
|
|||||||
--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(0.985 0.006 83);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.255 0.012 64);
|
--foreground: oklch(0.145 0 0);
|
||||||
--card: oklch(0.998 0.003 90);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.255 0.012 64);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(0.998 0.003 90);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.255 0.012 64);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: oklch(0.62 0.128 62);
|
--primary: oklch(0.205 0 0);
|
||||||
--primary-foreground: oklch(0.99 0.006 90);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.945 0.014 78);
|
--secondary: oklch(0.97 0 0);
|
||||||
--secondary-foreground: oklch(0.32 0.014 60);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
--muted: oklch(0.945 0.012 78);
|
--muted: oklch(0.97 0 0);
|
||||||
--muted-foreground: oklch(0.535 0.02 62);
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
--accent: oklch(0.92 0.03 70);
|
--accent: oklch(0.97 0 0);
|
||||||
--accent-foreground: oklch(0.3 0.035 55);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.9 0.014 75);
|
--border: oklch(0.922 0 0);
|
||||||
--input: oklch(0.9 0.014 75);
|
--input: oklch(0.922 0 0);
|
||||||
--ring: oklch(0.62 0.128 62);
|
--ring: oklch(0.708 0 0);
|
||||||
--chart-1: oklch(0.66 0.14 60);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.7 0.12 95);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.58 0.09 45);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
--chart-4: oklch(0.75 0.13 80);
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
--chart-5: oklch(0.6 0.11 30);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--sidebar: oklch(0.965 0.01 80);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.255 0.012 64);
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
--sidebar-primary: oklch(0.62 0.128 62);
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
--sidebar-primary-foreground: oklch(0.99 0.006 90);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.92 0.03 70);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.3 0.035 55);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.9 0.014 75);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.62 0.128 62);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
--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.19 0.012 58);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.955 0.008 85);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.232 0.014 56);
|
--card: oklch(0.205 0 0);
|
||||||
--card-foreground: oklch(0.955 0.008 85);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.232 0.014 56);
|
--popover: oklch(0.205 0 0);
|
||||||
--popover-foreground: oklch(0.955 0.008 85);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.78 0.13 78);
|
--primary: oklch(0.922 0 0);
|
||||||
--primary-foreground: oklch(0.24 0.03 60);
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
--secondary: oklch(0.285 0.014 54);
|
--secondary: oklch(0.269 0 0);
|
||||||
--secondary-foreground: oklch(0.955 0.008 85);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.285 0.014 54);
|
--muted: oklch(0.269 0 0);
|
||||||
--muted-foreground: oklch(0.72 0.018 72);
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
--accent: oklch(0.33 0.032 56);
|
--accent: oklch(0.269 0 0);
|
||||||
--accent-foreground: oklch(0.955 0.008 85);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(0.92 0.03 85 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(0.92 0.03 85 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.78 0.13 78);
|
--ring: oklch(0.556 0 0);
|
||||||
--chart-1: oklch(0.78 0.13 78);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.7 0.12 50);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: oklch(0.72 0.11 95);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.65 0.13 35);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.6 0.1 60);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: oklch(0.165 0.012 58);
|
--sidebar: oklch(0.205 0 0);
|
||||||
--sidebar-foreground: oklch(0.955 0.008 85);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.78 0.13 78);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.24 0.03 60);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.33 0.032 56);
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.955 0.008 85);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(0.92 0.03 85 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.78 0.13 78);
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
--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 {
|
||||||
@@ -147,7 +130,7 @@
|
|||||||
/* Scrollbar styling */
|
/* Scrollbar styling */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--scrollbar-thumb) transparent;
|
scrollbar-color: oklch(1 0 0 / 20%) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
@@ -160,12 +143,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: var(--scrollbar-thumb);
|
background: oklch(1 0 0 / 20%);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--scrollbar-thumb-hover);
|
background: oklch(1 0 0 / 35%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Message cards: more prominent scrollbar than the subtle global default.
|
/* Message cards: more prominent scrollbar than the subtle global default.
|
||||||
@@ -177,13 +160,13 @@
|
|||||||
|
|
||||||
.scrollbar-card::-webkit-scrollbar-thumb,
|
.scrollbar-card::-webkit-scrollbar-thumb,
|
||||||
.scrollbar-card ::-webkit-scrollbar-thumb {
|
.scrollbar-card ::-webkit-scrollbar-thumb {
|
||||||
background: var(--scrollbar-thumb-strong);
|
background: oklch(1 0 0 / 30%);
|
||||||
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: var(--scrollbar-thumb-strong-hover);
|
background: oklch(1 0 0 / 50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Message layout: the compose editor and the posted card derive their widths
|
/* Message layout: the compose editor and the posted card derive their widths
|
||||||
@@ -203,39 +186,3 @@
|
|||||||
.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,26 +1,10 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html class="dark">
|
||||||
<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,7 +1,6 @@
|
|||||||
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,13 +55,6 @@ 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,7 +31,6 @@
|
|||||||
"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,4 +1,3 @@
|
|||||||
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';
|
||||||
@@ -137,45 +136,6 @@ 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,7 +6,6 @@ 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>;
|
||||||
@@ -146,21 +145,14 @@ export const TextPropertiesSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
|
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
|
||||||
|
|
||||||
export const ChecklistItemSchema = z.object({
|
export const QuestPropertiesSchema = 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(),
|
||||||
notes: z.string().optional(),
|
description: z.string(),
|
||||||
checklist: z.array(ChecklistItemSchema).optional(),
|
status: z.string().optional(),
|
||||||
// humanId
|
// humanId
|
||||||
assigned_to: z.string().optional(),
|
assigned_to: z.string().optional(),
|
||||||
done: z.boolean(),
|
|
||||||
});
|
});
|
||||||
export type TaskProperties = z.infer<typeof TaskPropertiesSchema>;
|
export type QuestProperties = z.infer<typeof QuestPropertiesSchema>;
|
||||||
|
|
||||||
export const PaperPropertiesSchema = z.object({
|
export const PaperPropertiesSchema = z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
@@ -201,7 +193,7 @@ export interface ParticlePropertiesMap {
|
|||||||
media: MediaProperties;
|
media: MediaProperties;
|
||||||
file: FileProperties;
|
file: FileProperties;
|
||||||
text: TextProperties;
|
text: TextProperties;
|
||||||
task: TaskProperties;
|
quest: QuestProperties;
|
||||||
paper: PaperProperties;
|
paper: PaperProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,9 +247,8 @@ export const ParticleSchema = z.discriminatedUnion('type', [
|
|||||||
...TombstoneFields,
|
...TombstoneFields,
|
||||||
}),
|
}),
|
||||||
ParticleBaseSchema.extend({
|
ParticleBaseSchema.extend({
|
||||||
type: z.literal('task'),
|
type: z.literal('quest'),
|
||||||
properties: TaskPropertiesSchema,
|
properties: QuestPropertiesSchema,
|
||||||
reactions: ReactionsSchema,
|
|
||||||
...TombstoneFields,
|
...TombstoneFields,
|
||||||
}),
|
}),
|
||||||
ParticleBaseSchema.extend({
|
ParticleBaseSchema.extend({
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Image, Text, View } from 'react-native';
|
import { 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' | 'lg' | 'xl';
|
type Size = 'xs' | 'sm' | 'md';
|
||||||
|
|
||||||
interface AvatarProps {
|
interface AvatarProps {
|
||||||
humanId: string | null | undefined;
|
humanId: string | null | undefined;
|
||||||
@@ -14,8 +13,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,15 +20,12 @@ 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 },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Human avatar: renders the profile picture when one is set (resolved to a
|
* Initials avatar with optional online ring (green) and an optional outer
|
||||||
* signed URL via React Query), otherwise initials. Supports an optional online
|
* stack ring used to visually separate overlapping avatars on a busy chrome.
|
||||||
* ring (green) and an outer stack separator ring used to keep overlapping
|
* Matches desktop's avatar + presence pattern (`ring-2 ring-green-500`).
|
||||||
* avatars distinct on busy chrome. Matches desktop's avatar + presence pattern.
|
|
||||||
*/
|
*/
|
||||||
export function Avatar({
|
export function Avatar({
|
||||||
humanId,
|
humanId,
|
||||||
@@ -39,22 +33,15 @@ export function Avatar({
|
|||||||
size = 'sm',
|
size = 'sm',
|
||||||
online = false,
|
online = false,
|
||||||
stackBg,
|
stackBg,
|
||||||
fallbackInitials,
|
|
||||||
className,
|
className,
|
||||||
}: AvatarProps) {
|
}: AvatarProps) {
|
||||||
const display = resolveHumanDisplay(humanId, humans);
|
const { initials } = 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 overflow-hidden rounded-full',
|
'bg-black/15 items-center justify-center rounded-full',
|
||||||
dims.box,
|
dims.box,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -65,13 +52,9 @@ export function Avatar({
|
|||||||
borderColor: online ? '#22c55e' : (stackBg ?? 'transparent'),
|
borderColor: online ? '#22c55e' : (stackBg ?? 'transparent'),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{avatarUrl ? (
|
<Text className={cn('text-white font-semibold', dims.text)}>
|
||||||
<Image source={{ uri: avatarUrl }} className="h-full w-full" />
|
{initials}
|
||||||
) : (
|
</Text>
|
||||||
<Text className={cn('text-white font-semibold', dims.text)}>
|
|
||||||
{initials}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
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,11 +1,6 @@
|
|||||||
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 {
|
import { Mic, Type as TypeIcon, Video as VideoIcon } from 'lucide-react-native';
|
||||||
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';
|
||||||
@@ -13,18 +8,13 @@ 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 {
|
import { createTextParticle, uploadMediaParticle } from '@/lib/upload';
|
||||||
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';
|
||||||
@@ -58,12 +48,6 @@ 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>;
|
||||||
/**
|
/**
|
||||||
@@ -79,7 +63,6 @@ export function ComposeDock({
|
|||||||
networkId,
|
networkId,
|
||||||
targetPath,
|
targetPath,
|
||||||
silentPresence = false,
|
silentPresence = false,
|
||||||
allowTask = true,
|
|
||||||
submitMedia,
|
submitMedia,
|
||||||
submitText: submitTextOverride,
|
submitText: submitTextOverride,
|
||||||
onParticleCreated,
|
onParticleCreated,
|
||||||
@@ -89,7 +72,6 @@ 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();
|
||||||
@@ -97,13 +79,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 || taskOpen;
|
const isComposing = ui.kind !== 'idle' || textOpen;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setComposing(isComposing);
|
setComposing(isComposing);
|
||||||
return () => setComposing(false);
|
return () => setComposing(false);
|
||||||
}, [isComposing, setComposing]);
|
}, [isComposing, setComposing]);
|
||||||
|
|
||||||
useComposingBroadcast({ ui, textOpen, taskOpen, silent: silentPresence });
|
useComposingBroadcast({ ui, textOpen, silent: silentPresence });
|
||||||
|
|
||||||
const ensurePermissions = useCallback(
|
const ensurePermissions = useCallback(
|
||||||
async (forVideo: boolean): Promise<boolean> => {
|
async (forVideo: boolean): Promise<boolean> => {
|
||||||
@@ -205,20 +187,6 @@ 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';
|
||||||
|
|
||||||
@@ -228,31 +196,27 @@ 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 px-8 pb-10"
|
className="flex-row items-center justify-between px-8 pb-10"
|
||||||
>
|
>
|
||||||
{/* Left and right clusters flex equally so the record button stays
|
<Pressable
|
||||||
centered regardless of how many side controls are present. */}
|
onPress={() =>
|
||||||
<View className="flex-1 flex-row items-center">
|
setMode((m) => (m === 'video' ? 'audio' : 'video'))
|
||||||
<Pressable
|
}
|
||||||
onPress={() =>
|
disabled={ui.kind !== 'idle'}
|
||||||
setMode((m) => (m === 'video' ? 'audio' : 'video'))
|
accessibilityLabel={`Switch to ${
|
||||||
}
|
mode === 'video' ? 'audio' : 'video'
|
||||||
disabled={ui.kind !== 'idle'}
|
} mode`}
|
||||||
accessibilityLabel={`Switch to ${
|
className={cn(
|
||||||
mode === 'video' ? 'audio' : 'video'
|
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||||
} mode`}
|
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',
|
{mode === 'video' ? (
|
||||||
)}
|
<VideoIcon color="white" size={20} strokeWidth={1.6} />
|
||||||
>
|
) : (
|
||||||
{mode === 'video' ? (
|
<Mic color="white" size={20} strokeWidth={1.6} />
|
||||||
<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
|
||||||
@@ -266,33 +230,17 @@ 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>
|
||||||
|
|
||||||
<View className="flex-1 flex-row items-center justify-end gap-3">
|
<Pressable
|
||||||
{allowTask ? (
|
onPress={() => setTextOpen(true)}
|
||||||
<Pressable
|
disabled={ui.kind !== 'idle'}
|
||||||
onPress={() => setTaskOpen(true)}
|
accessibilityLabel="Compose text"
|
||||||
disabled={ui.kind !== 'idle'}
|
className={cn(
|
||||||
accessibilityLabel="Create task"
|
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||||
className={cn(
|
ui.kind !== 'idle' && 'opacity-40',
|
||||||
'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}
|
||||||
@@ -329,12 +277,6 @@ export function ComposeDock({
|
|||||||
onClose={() => setTextOpen(false)}
|
onClose={() => setTextOpen(false)}
|
||||||
onSubmit={submitText}
|
onSubmit={submitText}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TaskComposeSheet
|
|
||||||
open={taskOpen}
|
|
||||||
onClose={() => setTaskOpen(false)}
|
|
||||||
onSubmit={submitTask}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -342,23 +284,17 @@ 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'
|
ui.kind === 'recording' ? 'recording' : textOpen ? 'typing' : null;
|
||||||
? 'recording'
|
|
||||||
: textOpen || taskOpen
|
|
||||||
? 'typing'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (silent || !broadcast) return;
|
if (silent || !broadcast) return;
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
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,7 +1,6 @@
|
|||||||
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 {
|
||||||
@@ -216,6 +215,7 @@ 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,7 +234,9 @@ 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">
|
||||||
<Avatar humanId={identity} humans={humans} size="lg" />
|
<View className="h-16 w-16 items-center justify-center rounded-full bg-neutral-700">
|
||||||
|
<Text className="text-white text-xl font-semibold">{initials}</Text>
|
||||||
|
</View>
|
||||||
</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">
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
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="[email protected]"
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
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,7 +13,6 @@ 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;
|
||||||
@@ -27,12 +26,7 @@ interface DrawerProps {
|
|||||||
onNavigateSettings: () => void;
|
onNavigateSettings: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Drawer({
|
export function Drawer({ open, onClose, onNavigateAccount }: DrawerProps) {
|
||||||
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));
|
||||||
@@ -59,6 +53,8 @@ export function Drawer({
|
|||||||
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}
|
||||||
@@ -89,11 +85,11 @@ export function Drawer({
|
|||||||
>
|
>
|
||||||
<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">
|
||||||
<Avatar
|
<View className="bg-sidebar-accent h-10 w-10 items-center justify-center rounded-full">
|
||||||
humanId={user?.id}
|
<Text className="text-sidebar-accent-foreground text-sm font-semibold">
|
||||||
humans={user ? [user] : undefined}
|
{initials}
|
||||||
size="md"
|
</Text>
|
||||||
/>
|
</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"
|
||||||
@@ -118,13 +114,6 @@ export function Drawer({
|
|||||||
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,28 +8,22 @@ 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 { Plus } from 'lucide-react-native';
|
import type { Network } from '@/api/types';
|
||||||
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
|
||||||
@@ -38,13 +32,11 @@ export function NetworkListScreen({
|
|||||||
const onRefresh = useCallback(async () => {
|
const onRefresh = useCallback(async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
await Promise.all([refetch(), refetchInvitations()]);
|
await refetch();
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
}, [refetch, refetchInvitations]);
|
}, [refetch]);
|
||||||
|
|
||||||
const pendingInvitations = invitations ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
@@ -52,21 +44,14 @@ export function NetworkListScreen({
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setDrawerOpen(true)}
|
onPress={() => setDrawerOpen(true)}
|
||||||
accessibilityLabel="Open menu"
|
accessibilityLabel="Open menu"
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
humanId={user?.id}
|
|
||||||
humans={user ? [user] : undefined}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
<FlowyLogo />
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setCreateOpen(true)}
|
|
||||||
accessibilityLabel="Create network"
|
|
||||||
className="bg-muted h-9 w-9 items-center justify-center rounded-full"
|
className="bg-muted h-9 w-9 items-center justify-center rounded-full"
|
||||||
>
|
>
|
||||||
<Plus size={18} color="#fafafa" />
|
<Text className="text-muted-foreground text-xs font-semibold">
|
||||||
|
{initials}
|
||||||
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
<FlowyLogo />
|
||||||
|
<View className="w-9" />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -82,21 +67,16 @@ 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) && pendingInvitations.length === 0 ? (
|
) : !data || data.length === 0 ? (
|
||||||
<EmptyState onCreate={() => setCreateOpen(true)} />
|
<EmptyState />
|
||||||
) : (
|
) : (
|
||||||
<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}
|
||||||
@@ -114,65 +94,10 @@ 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,
|
||||||
@@ -199,23 +124,15 @@ function NetworkCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyState({ onCreate }: { onCreate: () => void }) {
|
function EmptyState() {
|
||||||
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">
|
||||||
Create one to get started, or ask a friend for an invite.
|
Ask a friend for an invite, or create one on desktop.
|
||||||
</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,68 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { Pressable, Text, View } from 'react-native';
|
||||||
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']}>
|
||||||
@@ -76,38 +18,7 @@ export function AccountScreen({ navigation }: RootStackScreenProps<'Account'>) {
|
|||||||
<View className="w-8" />
|
<View className="w-8" />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="items-center px-6 py-8">
|
<View className="px-6 py-6 gap-4">
|
||||||
<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,47 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { Pressable, Text, View } from 'react-native';
|
||||||
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">
|
||||||
@@ -54,55 +17,11 @@ export function SettingsScreen({
|
|||||||
<View className="w-8" />
|
<View className="w-8" />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="px-4 py-4">
|
<View className="flex-1 items-center justify-center px-6">
|
||||||
<SettingsGroup title="Notifications">
|
<Text className="text-muted-foreground text-center">
|
||||||
<View className="flex-row items-center justify-between px-4 py-3">
|
Theme, notifications, and account preferences land here later.
|
||||||
<Text className="text-foreground text-base">
|
</Text>
|
||||||
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,10 +1,22 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Text, View } from 'react-native';
|
import { Text, View } from 'react-native';
|
||||||
import { HelpCircle } from 'lucide-react-native';
|
import {
|
||||||
|
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 {
|
||||||
@@ -14,10 +26,6 @@ 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,
|
||||||
@@ -29,8 +37,25 @@ export function FallbackParticleView({
|
|||||||
particle.created_by_human_id,
|
particle.created_by_human_id,
|
||||||
network?.humans,
|
network?.humans,
|
||||||
);
|
);
|
||||||
const Icon = HelpCircle;
|
const meta = TYPE_META[particle.type] ?? {
|
||||||
const title = particle.type === 'folder' ? particle.properties.name : null;
|
icon: HelpCircle,
|
||||||
|
label: particle.type,
|
||||||
|
};
|
||||||
|
const Icon = meta.icon;
|
||||||
|
const title = (() => {
|
||||||
|
switch (particle.type) {
|
||||||
|
case 'quest':
|
||||||
|
return particle.properties.title;
|
||||||
|
case 'paper':
|
||||||
|
return particle.properties.title;
|
||||||
|
case 'file':
|
||||||
|
return particle.properties.filename;
|
||||||
|
case 'folder':
|
||||||
|
return particle.properties.name;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (paused) return;
|
if (paused) return;
|
||||||
@@ -45,7 +70,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">
|
||||||
{particle.type}
|
{meta.label}
|
||||||
</Text>
|
</Text>
|
||||||
{title ? (
|
{title ? (
|
||||||
<Text className="text-white/70 text-sm" numberOfLines={2}>
|
<Text className="text-white/70 text-sm" numberOfLines={2}>
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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 { Avatar } from '@/components/Avatar';
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
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,6 +77,7 @@ 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}
|
||||||
@@ -92,7 +93,11 @@ export function ReactionStack({
|
|||||||
: null,
|
: null,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Avatar humanId={reactors[0]} humans={humans} size="xs" />
|
<View className="bg-white/15 h-5 w-5 items-center justify-center rounded-full">
|
||||||
|
<Text className="text-white text-[9px] font-semibold">
|
||||||
|
{firstReactor.initials}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
<Text className="text-white/90 text-xs" numberOfLines={1}>
|
<Text className="text-white/90 text-xs" numberOfLines={1}>
|
||||||
{text}
|
{text}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
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,15 +53,11 @@ 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';
|
||||||
@@ -460,37 +456,6 @@ 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
|
||||||
@@ -579,6 +544,31 @@ 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>
|
||||||
|
|
||||||
@@ -634,20 +624,6 @@ 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
|
||||||
|
|||||||
@@ -1,435 +0,0 @@
|
|||||||
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 } from 'react';
|
import { useEffect, useRef, type ReactNode } from 'react';
|
||||||
import { ScrollView, Text, View } from 'react-native';
|
import { Platform, ScrollView, Text, View, type ViewStyle } 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,6 +47,156 @@ 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,
|
||||||
@@ -58,6 +208,11 @@ 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">
|
||||||
@@ -135,7 +290,7 @@ export function TextParticleView({
|
|||||||
showsVerticalScrollIndicator
|
showsVerticalScrollIndicator
|
||||||
indicatorStyle="white"
|
indicatorStyle="white"
|
||||||
>
|
>
|
||||||
<MarkdownBody content={content} />
|
{markdownNodes}
|
||||||
{editedLabel}
|
{editedLabel}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -189,7 +189,6 @@ export function NewStreamScreen({
|
|||||||
networkId={networkId}
|
networkId={networkId}
|
||||||
targetPath={placeholderPath}
|
targetPath={placeholderPath}
|
||||||
silentPresence
|
silentPresence
|
||||||
allowTask={false}
|
|
||||||
submitMedia={submitMedia}
|
submitMedia={submitMedia}
|
||||||
submitText={submitText}
|
submitText={submitText}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ 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 } from '@/lib/utils';
|
import { cn, getInitials } from '@/lib/utils';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
interface StreamCardProps {
|
interface StreamCardProps {
|
||||||
@@ -36,20 +35,34 @@ 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:'));
|
||||||
|
|
||||||
// The human represented by the card: the other party in a DM, otherwise the
|
const initials = useMemo(() => {
|
||||||
// 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) return otherEntry.replace('human:', '');
|
if (otherEntry) {
|
||||||
|
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]);
|
|
||||||
|
|
||||||
const fallbackInitials = particle.properties.name.slice(0, 2).toUpperCase();
|
if (latestChild) {
|
||||||
|
const creator = network?.humans?.find(
|
||||||
|
(h) => h.id === latestChild.created_by_human_id,
|
||||||
|
);
|
||||||
|
if (creator) return getInitials(creator.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
return particle.properties.name.slice(0, 2).toUpperCase();
|
||||||
|
}, [
|
||||||
|
isDM,
|
||||||
|
particle.visible_to,
|
||||||
|
particle.properties.name,
|
||||||
|
userId,
|
||||||
|
latestChild,
|
||||||
|
network,
|
||||||
|
]);
|
||||||
|
|
||||||
const isUnseen = useMemo(() => {
|
const isUnseen = useMemo(() => {
|
||||||
if (!latestChild) return false;
|
if (!latestChild) return false;
|
||||||
@@ -74,7 +87,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 'task':
|
case 'quest':
|
||||||
return latestChild.properties.title;
|
return latestChild.properties.title;
|
||||||
case 'paper':
|
case 'paper':
|
||||||
return latestChild.properties.title;
|
return latestChild.properties.title;
|
||||||
@@ -89,12 +102,21 @@ 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"
|
||||||
>
|
>
|
||||||
<Avatar
|
<View
|
||||||
humanId={avatarHumanId}
|
className={cn(
|
||||||
humans={network?.humans}
|
'h-10 w-10 items-center justify-center rounded-full',
|
||||||
size="md"
|
isUnseen ? 'bg-primary' : 'bg-muted',
|
||||||
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,7 +6,6 @@ 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';
|
||||||
@@ -33,9 +32,6 @@ 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 ? (
|
||||||
@@ -71,15 +67,7 @@ export function StreamListScreen({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Header({
|
function Header({ title, onBack }: { title: string; onBack: () => void }) {
|
||||||
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
|
||||||
@@ -95,14 +83,7 @@ function Header({
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<View className="w-8" />
|
||||||
onPress={onOpenSettings}
|
|
||||||
className="px-2 py-1"
|
|
||||||
accessibilityLabel="Network settings"
|
|
||||||
hitSlop={8}
|
|
||||||
>
|
|
||||||
<SettingsIcon size={20} color="#fafafa" />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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 'task':
|
case 'quest':
|
||||||
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,32 +103,6 @@ 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,7 +7,6 @@ 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';
|
||||||
@@ -55,11 +54,6 @@ 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,7 +15,6 @@ export type RootStackParamList = {
|
|||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
};
|
};
|
||||||
NewStream: { networkId: string };
|
NewStream: { networkId: string };
|
||||||
NetworkSettings: { networkId: string };
|
|
||||||
Settings: undefined;
|
Settings: undefined;
|
||||||
Account: undefined;
|
Account: undefined;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,8 +63,6 @@ interface AuthState {
|
|||||||
requestCode: (email: string) => Promise<void>;
|
requestCode: (email: string) => Promise<void>;
|
||||||
signIn: (email: string, code: string) => Promise<void>;
|
signIn: (email: string, code: string) => Promise<void>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
/** Re-fetch the current user from Orion (e.g. after an avatar change). */
|
|
||||||
refreshUser: () => Promise<void>;
|
|
||||||
/**
|
/**
|
||||||
* Wipes the session in response to a server-detected auth failure (e.g. a
|
* Wipes the session in response to a server-detected auth failure (e.g. a
|
||||||
* 401 surfaced through react-query). Does not call `/auth/sign-out`; the
|
* 401 surfaced through react-query). Does not call `/auth/sign-out`; the
|
||||||
@@ -168,15 +166,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshUser: async () => {
|
|
||||||
try {
|
|
||||||
const user = await apiClient.me();
|
|
||||||
set({ user });
|
|
||||||
} catch (err) {
|
|
||||||
logError(err, { scope: 'auth.refreshUser' });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
invalidateSession: async () => {
|
invalidateSession: async () => {
|
||||||
stopPushTokenSync();
|
stopPushTokenSync();
|
||||||
apiClient.setToken(null);
|
apiClient.setToken(null);
|
||||||
|
|||||||
@@ -4278,18 +4278,6 @@ expo-haptics@~15.0.7:
|
|||||||
resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.8.tgz#f93f895ac5d76fe0c5ac26b3644e1dbb097833f3"
|
resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.8.tgz#f93f895ac5d76fe0c5ac26b3644e1dbb097833f3"
|
||||||
integrity sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==
|
integrity sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==
|
||||||
|
|
||||||
expo-image-loader@~6.0.0:
|
|
||||||
version "6.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-6.0.0.tgz#15230442cbb90e101c080a4c81e37d974e43e072"
|
|
||||||
integrity sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==
|
|
||||||
|
|
||||||
expo-image-picker@~17.0.8:
|
|
||||||
version "17.0.11"
|
|
||||||
resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.11.tgz#8f1537df946f7c19747ba9d0043ef7e3e8cb67dc"
|
|
||||||
integrity sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ==
|
|
||||||
dependencies:
|
|
||||||
expo-image-loader "~6.0.0"
|
|
||||||
|
|
||||||
expo-keep-awake@~15.0.8:
|
expo-keep-awake@~15.0.8:
|
||||||
version "15.0.8"
|
version "15.0.8"
|
||||||
resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz#911c5effeba9baff2ccde79ef0ff5bf856215f8d"
|
resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz#911c5effeba9baff2ccde79ef0ff5bf856215f8d"
|
||||||
|
|||||||
Reference in New Issue
Block a user