From 21dcf5d947e958d4b74e064d4ea5810889818f36 Mon Sep 17 00:00:00 2001 From: william arzt Date: Sat, 18 Jul 2026 18:52:35 -0400 Subject: [PATCH 01/19] feat: add buckets channel prototype --- PRODUCT.md | 33 + .../app/fixtures/BucketsChannel.fixture.tsx | 517 +++++++++++++ .../BucketFileViewer.native.tsx | 144 ++++ .../BucketFileViewer.shared.test.ts | 15 + .../BucketsChannel/BucketFileViewer.shared.ts | 40 + .../BucketsChannel/BucketFileViewer.tsx | 131 ++++ .../BucketsChannel/BucketsChannel.tsx | 722 ++++++++++++++++++ .../app/ui/components/BucketsChannel/index.ts | 3 + .../ui/components/Channel/ChannelHeader.tsx | 16 +- packages/app/ui/components/SearchBar.tsx | 4 +- packages/app/ui/index.tsx | 1 + 11 files changed, 1623 insertions(+), 3 deletions(-) create mode 100644 PRODUCT.md create mode 100644 packages/app/fixtures/BucketsChannel.fixture.tsx create mode 100644 packages/app/ui/components/BucketsChannel/BucketFileViewer.native.tsx create mode 100644 packages/app/ui/components/BucketsChannel/BucketFileViewer.shared.test.ts create mode 100644 packages/app/ui/components/BucketsChannel/BucketFileViewer.shared.ts create mode 100644 packages/app/ui/components/BucketsChannel/BucketFileViewer.tsx create mode 100644 packages/app/ui/components/BucketsChannel/BucketsChannel.tsx create mode 100644 packages/app/ui/components/BucketsChannel/index.ts diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000000..d0a287be49 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,33 @@ +# Product + +## Register + +product + +## Users + +Tlon Messenger group members, group administrators, and Tlonbots collaborating inside an existing group. People may arrive to quickly retrieve a file, organize an active project, or review what another member or agent changed. Tlonbots participate through their own ship identities and permissions rather than inheriting their human owner's authority. + +## Product Purpose + +Tlon Messenger is a group communication and collaboration product built on Urbit. Buckets extends a group with durable, group-owned file storage: a shared Drive or Dropbox that members and authorized Tlonbots can browse and change. Success means a member can understand where a file lives, who or what changed it, and what actions are available without needing to understand object storage, while files remain with the group when an uploader leaves. + +## Brand Personality + +Familiar, calm, communal. The interface should feel like a native part of Tlon Messenger and earn trust through predictable file-management patterns, concise language, and visible authorship. Agents are ordinary collaborators with a subtle bot marker, not a separate novelty layer. + +## Anti-references + +Do not resemble an S3 administration console, a second social gallery or feed, or a heavyweight enterprise document-management suite. Avoid storage jargon, dashboard decoration, card-grid sprawl, agent theater, and controls that imply per-file capabilities the backend cannot yet enforce. + +## Design Principles + +1. Make group ownership unmistakable: files belong to the shared space, not their uploader. +2. Use earned familiarity: folders, breadcrumbs, list and grid views, selection, and direct manipulation should behave like trusted file tools. +3. Treat humans and agents as accountable peers: authorize and attribute every action to the acting ship. +4. Reveal complexity progressively: keep retrieval and upload immediate while placing metadata, activity, and administration one layer deeper. +5. Preserve continuity with Messenger: reuse its channel shell, identity treatment, media previews, upload feedback, and responsive navigation. + +## Accessibility & Inclusion + +Target WCAG 2.2 AA. All file operations must work with keyboard and screen readers, have visible focus, use color-independent state cues, maintain touch targets of at least 44 by 44 points on mobile, respect reduced motion, and handle long user-generated names without hiding essential actions. diff --git a/packages/app/fixtures/BucketsChannel.fixture.tsx b/packages/app/fixtures/BucketsChannel.fixture.tsx new file mode 100644 index 0000000000..3d16d6434d --- /dev/null +++ b/packages/app/fixtures/BucketsChannel.fixture.tsx @@ -0,0 +1,517 @@ +import * as db from '@tloncorp/shared/db'; +import { useEffect, useMemo, useState } from 'react'; +import { Linking, useWindowDimensions } from 'react-native'; + +import { + BucketFileViewer, + BucketItem, + BucketSearchResult, + BucketsHeaderActions, + BucketsNewSheet, + BucketsPane, + BucketsPaneState, + BucketsSearchScreen, + ChannelHeader, + ChannelHeaderItemsProvider, + ScreenHeader, + XStack, + YStack, + useIsWindowNarrow, + useRegisterChannelHeaderItem, +} from '../ui'; +import { FixtureWrapper } from './FixtureWrapper'; + +const rootFolders: BucketItem[] = [ + folder('launch', 'Launch', 6), + folder('brand', 'Brand', 12), + folder('research', 'Research', 8), + folder('archive', 'Archive', 24), +]; + +const searchRevealFolder = folder('field-notes', 'Field notes', 48); +const searchRevealFiles = Array.from({ length: 48 }, (_, index) => + file( + `field-record-${index}`, + index === 24 ? 'meadow-survey.pdf' : `site-record-${index + 1}.pdf`, + index === 24 ? '~marzod' : '~zod', + index === 24 ? 'Yesterday' : 'Last week', + index === 24 ? '4.2 MB' : '1.8 MB', + 'application/pdf' + ) +); + +const initialFiles: Record = { + root: [], + launch: [ + file( + 'launch-brief', + 'launch-brief.pdf', + '~zod', + '12 min ago', + '2.4 MB', + 'application/pdf', + false, + 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf' + ), + file( + 'homepage-final', + 'homepage-final.fig', + '~marzod', + 'Today', + '18.1 MB', + 'application/x-figma' + ), + file( + 'demo-cut', + 'demo-cut.mp4', + 'Scout', + 'Today', + '84.6 MB', + 'video/mp4', + true, + 'https://d23dyxeqlo5psv.cloudfront.net/big_buck_bunny.mp4' + ), + { + ...file( + 'assets-pack', + 'assets-pack.zip', + '~zod', + 'Uploading', + '146 MB', + 'application/zip' + ), + uploadProgress: 42, + }, + ], + brand: [ + file( + 'brand-system', + 'brand-system.pdf', + '~marzod', + 'Yesterday', + '6.8 MB', + 'application/pdf' + ), + file( + 'wordmark', + 'wordmark.svg', + '~zod', + 'Monday', + '38 KB', + 'image/svg+xml', + false, + 'https://d2w9rnfcy7mm78.cloudfront.net/25296321/original_81eb3ac8a95ce36dc8d64b1038234ec8.jpg' + ), + ], + research: [ + file( + 'member-notes', + 'member-interviews.md', + 'Scout', + 'Yesterday', + '24 KB', + 'text/markdown', + true, + 'data:text/plain,', + '# Field interviews\n\nScout collected these notes during the first research pass.\n\n- Members want one shared place for project files.\n- Bots should use the same permissions as people.\n- Large uploads need visible progress and retry.' + ), + ], + archive: [], +}; + +const fakeChannel = { + id: 'buckets/~zod/project-files', + type: 'gallery', + title: 'Project Files', + description: '', +} as db.Channel; + +type FixtureState = BucketsPaneState | 'read-only'; +type SearchOrigin = { + activeFolderId: string | null; + selectedItemId: string | null; +}; + +function folder(id: string, name: string, itemCount: number): BucketItem { + return { + id, + kind: 'folder', + name, + author: '', + modifiedLabel: '', + itemCount, + }; +} + +function file( + id: string, + name: string, + author: string, + modifiedLabel: string, + sizeLabel: string, + mimeType: string, + isBot = false, + previewUri?: string, + textContent?: string +): BucketItem { + return { + id, + kind: 'file', + name, + author, + isBot, + mimeType, + modifiedLabel, + previewUri, + sizeLabel, + textContent, + }; +} + +function BucketsFixture({ + initialFolderId, + includeSearchRevealFolder = false, + initialSearchQuery = '', + newSheetInitiallyOpen = false, + searchInitiallyOpen = false, + state = 'populated', + viewport, +}: { + initialFolderId?: string | null; + includeSearchRevealFolder?: boolean; + initialSearchQuery?: string; + newSheetInitiallyOpen?: boolean; + searchInitiallyOpen?: boolean; + state?: FixtureState; + viewport: 'mobile' | 'desktop' | 'responsive'; +}) { + const { height: windowHeight } = useWindowDimensions(); + const folders = useMemo( + () => + includeSearchRevealFolder + ? [...rootFolders, searchRevealFolder] + : rootFolders, + [includeSearchRevealFolder] + ); + const isWindowNarrow = useIsWindowNarrow(); + const isMobileLayout = + viewport === 'mobile' || (viewport === 'responsive' && isWindowNarrow); + const [activeFolderId, setActiveFolderId] = useState( + initialFolderId === undefined + ? isMobileLayout + ? null + : 'launch' + : initialFolderId + ); + const [selectedItemId, setSelectedItemId] = useState( + activeFolderId === 'launch' ? 'launch-brief' : null + ); + const [newSheetOpen, setNewSheetOpen] = useState(newSheetInitiallyOpen); + const [searchOpen, setSearchOpen] = useState(searchInitiallyOpen); + const [searchOrigin, setSearchOrigin] = useState(null); + const [query, setQuery] = useState(initialSearchQuery); + const [previewItem, setPreviewItem] = useState(null); + const [localFiles, setLocalFiles] = useState(() => + includeSearchRevealFolder + ? { ...initialFiles, 'field-notes': searchRevealFiles } + : initialFiles + ); + const canEdit = state !== 'read-only'; + const paneState: BucketsPaneState = + state === 'read-only' ? 'populated' : state; + const activeFolder = folders.find((item) => item.id === activeFolderId); + const currentKey = activeFolderId ?? 'root'; + const rootItems = [...folders, ...(localFiles.root ?? [])]; + const visibleItems = activeFolderId + ? localFiles[currentKey] ?? [] + : rootItems; + const allSearchResults = useMemo(() => { + const results: BucketSearchResult[] = folders.map((item) => ({ + ...item, + parentFolderId: null, + pathLabel: 'Project Files', + })); + + Object.entries(localFiles).forEach(([folderId, items]) => { + const parentFolder = folders.find((item) => item.id === folderId); + const pathLabel = parentFolder + ? `Project Files / ${parentFolder.name}` + : 'Project Files'; + + items.forEach((item) => { + results.push({ + ...item, + parentFolderId: parentFolder?.id ?? null, + pathLabel, + }); + }); + }); + + return results; + }, [folders, localFiles]); + const normalizedQuery = query.trim().toLowerCase(); + const searchResults = normalizedQuery + ? allSearchResults.filter((item) => + [item.name, item.pathLabel, item.author, item.mimeType] + .filter(Boolean) + .join(' ') + .toLowerCase() + .includes(normalizedQuery) + ) + : []; + + useEffect(() => { + if (!isMobileLayout && activeFolderId === null) { + setActiveFolderId('launch'); + } + }, [activeFolderId, isMobileLayout]); + + const openItem = (item: BucketItem) => { + if (item.kind === 'folder') { + setActiveFolderId(item.id); + setSelectedItemId(null); + setQuery(''); + return; + } + setSelectedItemId(item.id); + if (item.previewUri) { + setPreviewItem(item); + } + }; + + const addFolder = () => { + const item = folder(`new-folder-${Date.now()}`, 'New folder', 0); + setLocalFiles((current) => ({ + ...current, + [currentKey]: [item, ...(current[currentKey] ?? [])], + })); + }; + + const addUpload = () => { + const item = { + ...file( + `field-photo-${Date.now()}`, + 'field-photo.jpg', + '~zod', + 'Uploading', + '4.8 MB', + 'image/jpeg' + ), + uploadProgress: 18, + }; + setLocalFiles((current) => ({ + ...current, + [currentKey]: [item, ...(current[currentKey] ?? [])], + })); + }; + + const openSearch = () => { + setSearchOrigin({ activeFolderId, selectedItemId }); + setSearchOpen(true); + setQuery(''); + }; + + const closeSearch = () => { + if (searchOrigin) { + setActiveFolderId(searchOrigin.activeFolderId); + setSelectedItemId(searchOrigin.selectedItemId); + } + setSearchOrigin(null); + setSearchOpen(false); + setQuery(''); + }; + + const openSearchResult = (result: BucketSearchResult) => { + setSearchOrigin((current) => current ?? { activeFolderId, selectedItemId }); + if (result.kind === 'folder') { + setActiveFolderId(result.id); + setSelectedItemId(null); + } else { + setActiveFolderId(result.parentFolderId); + setSelectedItemId(result.id); + } + setSearchOpen(false); + }; + + const paneProps = { + canEdit, + currentFolder: isMobileLayout ? undefined : activeFolder?.name, + items: paneState === 'empty' ? [] : visibleItems, + selectedItemId, + state: paneState, + onOpenItem: openItem, + onDeleteItem: (item: BucketItem) => + setLocalFiles((current) => ({ + ...current, + [currentKey]: (current[currentKey] ?? []).filter( + (candidate) => candidate.id !== item.id + ), + })), + onDownloadItem: (item: BucketItem) => setSelectedItemId(item.id), + onMoveItem: (item: BucketItem) => setSelectedItemId(item.id), + onRenameItem: (item: BucketItem) => setSelectedItemId(item.id), + }; + + return ( + + + + {previewItem?.previewUri ? ( + setPreviewItem(null)} + onOpenExternally={() => Linking.openURL(previewItem.previewUri!)} + /> + ) : searchOpen ? ( + + ) : isMobileLayout ? ( + + setNewSheetOpen(true)} + onSearch={openSearch} + /> + { + if (searchOrigin) { + setSearchOpen(true); + return; + } + if (activeFolderId) { + setActiveFolderId(null); + setSelectedItemId(null); + } + }} + hideIdentity + preferProvidedTitle + showSearchButton + title={activeFolder?.name ?? 'Project Files'} + /> + + + + ) : ( + + + setSearchOpen(true) : undefined + } + borderBottom + title="Project Files" + /> + + + + setNewSheetOpen(true)} + onSearch={openSearch} + /> + } + showSubtitle + subtitle="Bucket" + title="Project Files" + useHorizontalTitleLayout + /> + + + + + )} + + + + ); +} + +function RegisteredHeaderActions({ + canEdit, + onNew, + onSearch, +}: { + canEdit: boolean; + onNew: () => void; + onSearch: () => void; +}) { + const actions = useMemo( + () => ( + + ), + [canEdit, onNew, onSearch] + ); + useRegisterChannelHeaderItem(actions); + return null; +} + +export default { + Mobile: , + 'Mobile folder': ( + + ), + Desktop: , + Search: , + 'Search reveal': ( + + ), + 'New action': , + Empty: , + Loading: , + 'Read only': , +}; diff --git a/packages/app/ui/components/BucketsChannel/BucketFileViewer.native.tsx b/packages/app/ui/components/BucketsChannel/BucketFileViewer.native.tsx new file mode 100644 index 0000000000..f55bbf02dd --- /dev/null +++ b/packages/app/ui/components/BucketsChannel/BucketFileViewer.native.tsx @@ -0,0 +1,144 @@ +import { FilePreview, Image, Pressable, Text } from '@tloncorp/ui'; +import { VideoView, useVideoPlayer } from 'expo-video'; +import { Platform } from 'react-native'; +import { WebView } from 'react-native-webview'; +import { ScrollView, View, YStack } from 'tamagui'; + +import { useWebView } from '../../../hooks/useWebview'; +import { ScreenHeader } from '../ScreenHeader'; +import { + BucketFileViewerItem, + getBucketPreviewKind, +} from './BucketFileViewer.shared'; + +export function BucketFileViewer({ + item, + onClose, + onOpenExternally, +}: { + item: BucketFileViewerItem; + onClose: () => void; + onOpenExternally: () => void; +}) { + const previewKind = getBucketPreviewKind(item); + const webview = useWebView(); + + return ( + + + Open + + } + showSubtitle + subtitle={item.sizeLabel ?? 'File'} + title={item.name} + /> + + {previewKind === 'image' ? ( + {item.name} + ) : previewKind === 'video' ? ( + + ) : previewKind === 'pdf' && Platform.OS === 'ios' && webview ? ( + + ) : previewKind === 'text' && item.textContent ? ( + + + {item.textContent} + + + ) : ( + + )} + + + ); +} + +function NativeVideoPreview({ uri }: { uri: string }) { + const player = useVideoPlayer({ uri }, (videoPlayer) => { + videoPlayer.play(); + }); + + return ( + + + + ); +} + +function UnsupportedPreview({ + isAndroidPdf, + item, + onOpen, +}: { + isAndroidPdf: boolean; + item: BucketFileViewerItem; + onOpen: () => void; +}) { + return ( + + + + + {isAndroidPdf ? 'Open PDF to view' : 'Preview unavailable'} + + + {isAndroidPdf + ? 'PDFs open in your device viewer.' + : 'Open this file in another app to view it.'} + + + + + Open file + + + + ); +} diff --git a/packages/app/ui/components/BucketsChannel/BucketFileViewer.shared.test.ts b/packages/app/ui/components/BucketsChannel/BucketFileViewer.shared.test.ts new file mode 100644 index 0000000000..11ae85cbe4 --- /dev/null +++ b/packages/app/ui/components/BucketsChannel/BucketFileViewer.shared.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { getBucketPreviewKind } from './BucketFileViewer.shared'; + +describe('getBucketPreviewKind', () => { + it.each([ + ['photo.jpg', 'image/jpeg', 'image'], + ['demo.mp4', 'video/mp4', 'video'], + ['notes.md', undefined, 'text'], + ['report.pdf', undefined, 'pdf'], + ['archive.zip', 'application/zip', 'unsupported'], + ] as const)('classifies %s as %s', (name, mimeType, expected) => { + expect(getBucketPreviewKind({ name, mimeType })).toBe(expected); + }); +}); diff --git a/packages/app/ui/components/BucketsChannel/BucketFileViewer.shared.ts b/packages/app/ui/components/BucketsChannel/BucketFileViewer.shared.ts new file mode 100644 index 0000000000..9f5ed91d5e --- /dev/null +++ b/packages/app/ui/components/BucketsChannel/BucketFileViewer.shared.ts @@ -0,0 +1,40 @@ +export type BucketPreviewKind = + | 'image' + | 'video' + | 'text' + | 'pdf' + | 'unsupported'; + +export type BucketFileViewerItem = { + name: string; + mimeType?: string; + sizeLabel?: string; + uri: string; + textContent?: string; +}; + +export function getBucketPreviewKind({ + mimeType, + name, +}: Pick): BucketPreviewKind { + const normalizedMimeType = mimeType?.toLowerCase() ?? ''; + const extension = name.split('.').pop()?.toLowerCase(); + + if (normalizedMimeType.startsWith('image/')) { + return 'image'; + } + if (normalizedMimeType.startsWith('video/')) { + return 'video'; + } + if ( + normalizedMimeType.startsWith('text/') || + ['md', 'markdown', 'txt', 'json', 'csv'].includes(extension ?? '') + ) { + return 'text'; + } + if (normalizedMimeType === 'application/pdf' || extension === 'pdf') { + return 'pdf'; + } + + return 'unsupported'; +} diff --git a/packages/app/ui/components/BucketsChannel/BucketFileViewer.tsx b/packages/app/ui/components/BucketsChannel/BucketFileViewer.tsx new file mode 100644 index 0000000000..7d6d44107b --- /dev/null +++ b/packages/app/ui/components/BucketsChannel/BucketFileViewer.tsx @@ -0,0 +1,131 @@ +import { FilePreview, Image, Pressable, Text } from '@tloncorp/ui'; +import { ScrollView, View, YStack } from 'tamagui'; + +import { ScreenHeader } from '../ScreenHeader'; +import { + BucketFileViewerItem, + getBucketPreviewKind, +} from './BucketFileViewer.shared'; + +export function BucketFileViewer({ + item, + onClose, + onOpenExternally, +}: { + item: BucketFileViewerItem; + onClose: () => void; + onOpenExternally: () => void; +}) { + const previewKind = getBucketPreviewKind(item); + + return ( + + + Open + + } + showSubtitle + subtitle={item.sizeLabel ?? 'File'} + title={item.name} + useHorizontalTitleLayout + /> + + {previewKind === 'image' ? ( + {item.name} + ) : previewKind === 'video' ? ( + + + ) : previewKind === 'pdf' ? ( +