experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(ios): UX polish - 13 fixes for share sheet, editor, keyboard, sorting, filters

1. Share sheet: accept text-shared URLs (NSExtensionActivationSupportsText)
2. Auto-update version number from git commit count via Vite define
3. Undo/redo buttons in ResizableInput editor
4. Restore iOS keyboard suggestions (autoCorrect/spellCheck on content textarea)
5. Tag board expands when keyboard dismisses (via dynamic padding from fix 7)
6. Increase drag handle hit zone to 44px (Apple HIG minimum)
7. Close gap between editor card and keyboard using actual keyboardHeight
8. URL open button on text items containing URLs
9. Share extension tag filtering by text field input
10. Done button saves before closing share extension
11. Persist search text and selected filter tags across app restart
12. Sort toggle (newest/oldest) with localStorage persistence
13. Prevent resize drag from triggering pull-to-refresh

+273 -23
+2
backend/tauri-mobile/src-tauri/gen/apple/Peek/Info.plist
··· 28 28 <dict> 29 29 <key>NSExtensionActivationSupportsWebURLWithMaxCount</key> 30 30 <integer>1</integer> 31 + <key>NSExtensionActivationSupportsText</key> 32 + <true/> 31 33 </dict> 32 34 </dict> 33 35 <key>NSExtensionMainStoryboard</key>
+15 -1
backend/tauri-mobile/src-tauri/gen/apple/Peek/ShareViewController.swift
··· 1136 1136 var sharedMetadata: [String: Any] = [:] // Captured metadata from share extension 1137 1137 var selectedTags: Set<String> = [] 1138 1138 var availableTags: [TagStats] = [] 1139 + var tagFilterText = "" 1139 1140 var existingSavedItem: SavedItem? 1140 1141 var savedImageId: String? // Track saved image ID to prevent duplicates 1141 1142 var pendingImageUrl: String? // Store image URL when seen, for use by image handler ··· 1330 1331 newTagTextField.returnKeyType = .done 1331 1332 newTagTextField.translatesAutoresizingMaskIntoConstraints = false 1332 1333 newTagTextField.delegate = self 1334 + newTagTextField.addTarget(self, action: #selector(tagTextFieldChanged), for: .editingChanged) 1333 1335 inputContainerView.addSubview(newTagTextField) 1334 1336 1335 1337 addTagButton.setTitle("Add", for: .normal) ··· 1406 1408 ]) 1407 1409 } 1408 1410 1411 + @objc func tagTextFieldChanged() { 1412 + tagFilterText = newTagTextField.text?.trimmingCharacters(in: .whitespaces).lowercased() ?? "" 1413 + updateTagsUI() 1414 + } 1415 + 1409 1416 @objc func addTagPressed() { 1410 1417 guard let text = newTagTextField.text?.trimmingCharacters(in: .whitespaces).lowercased(), 1411 1418 !text.isEmpty else { return } ··· 1417 1424 } 1418 1425 1419 1426 newTagTextField.text = "" 1427 + tagFilterText = "" 1420 1428 updateTagsUI() 1421 1429 saveCurrentState() 1422 1430 } ··· 1951 1959 } 1952 1960 1953 1961 func getUnusedTags() -> [TagStats] { 1954 - return availableTags.filter { !selectedTags.contains($0.name) } 1962 + return availableTags.filter { tag in 1963 + !selectedTags.contains(tag.name) && 1964 + (tagFilterText.isEmpty || tag.name.lowercased().contains(tagFilterText)) 1965 + } 1955 1966 } 1956 1967 1957 1968 func getSortedSelectedTags() -> [String] { ··· 2045 2056 } 2046 2057 2047 2058 @objc func closePressed() { 2059 + saveCurrentState() 2048 2060 extensionContext?.completeRequest(returningItems: [], completionHandler: nil) 2049 2061 } 2050 2062 ··· 2096 2108 // Tapping unused tag adds it 2097 2109 let tag = getUnusedTags()[indexPath.item] 2098 2110 selectedTags.insert(tag.name) 2111 + tagFilterText = "" 2112 + newTagTextField.text = "" 2099 2113 } 2100 2114 updateTagsUI() 2101 2115 saveCurrentState()
+88 -3
backend/tauri-mobile/src/App.css
··· 2714 2714 bottom: 0; 2715 2715 left: 0; 2716 2716 right: 0; 2717 - height: 20px; 2717 + height: 44px; 2718 2718 transform: translateY(calc(50% + 0.5px)); 2719 2719 cursor: ns-resize; 2720 2720 display: flex; ··· 2736 2736 bottom: auto; 2737 2737 transform: none; 2738 2738 height: auto; 2739 - padding: 5px 0; 2739 + padding: 16px 0; 2740 2740 } 2741 2741 2742 2742 body.dark .resizable-input-textarea { ··· 3283 3283 /* Editor drag handle for swipe-to-close */ 3284 3284 .editor-drag-handle { 3285 3285 width: 100%; 3286 - padding: 8px 0; 3286 + padding: 16px 0; 3287 3287 display: flex; 3288 3288 align-items: center; 3289 3289 justify-content: center; ··· 3323 3323 min-height: 32px; 3324 3324 padding: 0.5rem; 3325 3325 } 3326 + 3327 + /* Undo/Redo buttons for editor */ 3328 + .undo-redo-buttons { 3329 + display: flex; 3330 + gap: 4px; 3331 + padding: 4px 12px; 3332 + justify-content: flex-end; 3333 + flex-shrink: 0; 3334 + } 3335 + 3336 + .undo-redo-buttons button { 3337 + background: none; 3338 + border: 1px solid #ddd; 3339 + border-radius: 6px; 3340 + padding: 4px 8px; 3341 + cursor: pointer; 3342 + color: #666; 3343 + display: flex; 3344 + align-items: center; 3345 + justify-content: center; 3346 + } 3347 + 3348 + .undo-redo-buttons button:disabled { 3349 + opacity: 0.3; 3350 + cursor: default; 3351 + } 3352 + 3353 + .undo-redo-buttons button:active:not(:disabled) { 3354 + background: rgba(0, 0, 0, 0.05); 3355 + } 3356 + 3357 + body.dark .undo-redo-buttons button { 3358 + border-color: #444; 3359 + color: #aaa; 3360 + } 3361 + 3362 + body.dark .undo-redo-buttons button:active:not(:disabled) { 3363 + background: rgba(255, 255, 255, 0.1); 3364 + } 3365 + 3366 + /* URL open button for text items */ 3367 + .card-open-url-btn { 3368 + background: none; 3369 + border: none; 3370 + color: #007AFF; 3371 + cursor: pointer; 3372 + padding: 4px; 3373 + display: flex; 3374 + align-items: center; 3375 + justify-content: center; 3376 + flex-shrink: 0; 3377 + } 3378 + 3379 + .card-open-url-btn:active { 3380 + opacity: 0.5; 3381 + } 3382 + 3383 + body.dark .card-open-url-btn { 3384 + color: #5AC8FA; 3385 + } 3386 + 3387 + /* Sort button in header */ 3388 + .sort-btn { 3389 + background: none; 3390 + border: none; 3391 + color: #666; 3392 + cursor: pointer; 3393 + padding: 6px; 3394 + display: flex; 3395 + align-items: center; 3396 + justify-content: center; 3397 + border-radius: 8px; 3398 + } 3399 + 3400 + .sort-btn:active { 3401 + background: rgba(0, 0, 0, 0.05); 3402 + } 3403 + 3404 + body.dark .sort-btn { 3405 + color: #aaa; 3406 + } 3407 + 3408 + body.dark .sort-btn:active { 3409 + background: rgba(255, 255, 255, 0.1); 3410 + }
+156 -19
backend/tauri-mobile/src/App.tsx
··· 4 4 import { openUrl } from "@tauri-apps/plugin-opener"; 5 5 import "./App.css"; 6 6 7 + declare const __BUILD_NUMBER__: string; 8 + 7 9 type ItemType = "page" | "text" | "tagset" | "image"; 8 10 9 11 interface ItemMetadata { ··· 144 146 145 147 const SWIPE_THRESHOLD = 80; 146 148 147 - const EditorOverlay: React.FC<EditorOverlayProps> = ({ children, onDismiss, keyboardHeight: _keyboardHeight, className = '' }) => { 148 - // Use fixed keyboard padding to avoid any dynamic changes 149 - // Standard iOS keyboard is ~300px, we use 350 to be safe 150 - const FIXED_KEYBOARD_PADDING = 350; 149 + const EditorOverlay: React.FC<EditorOverlayProps> = ({ children, onDismiss, keyboardHeight, className = '' }) => { 150 + // Use actual keyboard height when available, fall back to fixed estimate 151 + const effectiveKeyboardPadding = keyboardHeight > 0 ? keyboardHeight + 20 : 350; 151 152 152 153 // Swipe-down-to-close state 153 154 const [swipeOffset, setSwipeOffset] = useState(0); ··· 198 199 return ( 199 200 <div 200 201 className={`edit-overlay ${className}`} 201 - style={{ paddingBottom: `${FIXED_KEYBOARD_PADDING}px` }} 202 + style={{ paddingBottom: `${effectiveKeyboardPadding}px` }} 202 203 onClick={(e) => e.target === e.currentTarget && onDismiss()} 203 204 > 204 205 <div ··· 370 371 onChange, 371 372 placeholder = "Enter text...", 372 373 minHeightPercent = 0.5, 373 - keyboardHeight: _keyboardHeight, 374 + keyboardHeight, 374 375 autoFocus = false, 375 376 showClearButton = true, 376 377 onAutoSave ··· 382 383 const dragStartYRef = useRef(0); 383 384 const dragStartHeightRef = useRef(0); 384 385 385 - // Use fixed keyboard height for sizing to avoid layout jumps when QuickType changes 386 - // Standard iOS keyboard is ~300px, we use 350 to match EditorOverlay 387 - const FIXED_KEYBOARD_HEIGHT = 350; 386 + // Undo/redo history 387 + const historyRef = useRef<string[]>([value]); 388 + const historyIndexRef = useRef(0); 389 + const isUndoRedoRef = useRef(false); 390 + const [, forceRender] = useState(0); 391 + 392 + // Track value changes for undo history 393 + useEffect(() => { 394 + if (isUndoRedoRef.current) { 395 + isUndoRedoRef.current = false; 396 + return; 397 + } 398 + const history = historyRef.current; 399 + const idx = historyIndexRef.current; 400 + // Only push if different from current position 401 + if (history[idx] !== value) { 402 + // Truncate any redo history 403 + historyRef.current = history.slice(0, idx + 1); 404 + historyRef.current.push(value); 405 + // Bound to 100 entries 406 + if (historyRef.current.length > 100) { 407 + historyRef.current.shift(); 408 + } 409 + historyIndexRef.current = historyRef.current.length - 1; 410 + forceRender(n => n + 1); 411 + } 412 + }, [value]); 413 + 414 + const handleUndo = () => { 415 + if (historyIndexRef.current > 0) { 416 + historyIndexRef.current--; 417 + isUndoRedoRef.current = true; 418 + onChange(historyRef.current[historyIndexRef.current]); 419 + forceRender(n => n + 1); 420 + } 421 + }; 422 + 423 + const handleRedo = () => { 424 + if (historyIndexRef.current < historyRef.current.length - 1) { 425 + historyIndexRef.current++; 426 + isUndoRedoRef.current = true; 427 + onChange(historyRef.current[historyIndexRef.current]); 428 + forceRender(n => n + 1); 429 + } 430 + }; 431 + 432 + // Use actual keyboard height when available, fall back to fixed estimate 433 + const FIXED_KEYBOARD_HEIGHT = keyboardHeight > 0 ? keyboardHeight : 350; 388 434 389 435 // Calculate default height (initial size) and min height (resize floor) 390 436 const headerHeight = 56; ··· 401 447 dragStartHeightRef.current = wrapperRef.current.offsetHeight; 402 448 document.body.style.userSelect = 'none'; 403 449 document.body.style.cursor = 'ns-resize'; 450 + document.body.dataset.resizing = 'true'; 404 451 }; 405 452 406 453 const handleDragMove = (clientY: number) => { ··· 414 461 isDraggingRef.current = false; 415 462 document.body.style.userSelect = ''; 416 463 document.body.style.cursor = ''; 464 + delete document.body.dataset.resizing; 417 465 }; 418 466 419 467 // Auto markdown list continuation on Enter ··· 516 564 ref={wrapperRef} 517 565 style={{ height: `${currentHeight}px`, minHeight: `${minHeight}px` }} 518 566 > 567 + <div className="undo-redo-buttons"> 568 + <button type="button" onClick={handleUndo} disabled={historyIndexRef.current <= 0} title="Undo"> 569 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 570 + <polyline points="1 4 1 10 7 10"></polyline> 571 + <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path> 572 + </svg> 573 + </button> 574 + <button type="button" onClick={handleRedo} disabled={historyIndexRef.current >= historyRef.current.length - 1} title="Redo"> 575 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 576 + <polyline points="23 4 23 10 17 10"></polyline> 577 + <path d="M20.49 15a9 9 0 1 1-2.13-9.36L23 10"></path> 578 + </svg> 579 + </button> 580 + </div> 519 581 <textarea 520 582 ref={textareaRef} 521 583 className="resizable-input-textarea" ··· 535 597 }} 536 598 onKeyDown={handleKeyDown} 537 599 placeholder={placeholder} 538 - autoCapitalize="none" 539 - autoCorrect="off" 600 + autoCapitalize="sentences" 601 + autoCorrect="on" 540 602 autoComplete="off" 541 - spellCheck={false} 603 + spellCheck={true} 542 604 /> 543 605 {showClearButton && <ClearButton show={value.length > 0} onClear={() => onChange('')} className="textarea-clear" />} 544 606 <div ··· 641 703 // Delete confirmation state 642 704 const [pendingDelete, setPendingDelete] = useState<{ id: string; type: ItemType } | null>(null); 643 705 644 - // Search and tag filter state 645 - const [searchText, setSearchText] = useState(""); 646 - const [selectedFilterTags, setSelectedFilterTags] = useState<Set<string>>(new Set()); 706 + // Search and tag filter state (persisted to localStorage) 707 + const [searchText, setSearchText] = useState<string>(() => { 708 + return localStorage.getItem('searchText') || ""; 709 + }); 710 + const [selectedFilterTags, setSelectedFilterTags] = useState<Set<string>>(() => { 711 + const saved = localStorage.getItem('selectedFilterTags'); 712 + return saved ? new Set(JSON.parse(saved)) : new Set(); 713 + }); 714 + 715 + // Sort order state (persisted to localStorage) 716 + const [sortOrder, setSortOrder] = useState<'newest' | 'oldest'>(() => { 717 + return (localStorage.getItem('sortOrder') as 'newest' | 'oldest') || 'newest'; 718 + }); 647 719 648 720 // Filter tags resizable state 649 721 const [filterTagsHeight, setFilterTagsHeight] = useState<number>(() => { ··· 652 724 }); 653 725 const filterTagsRef = useRef<HTMLDivElement>(null); 654 726 const filterTagsDraggingRef = useRef(false); 727 + const isResizingRef = useRef(false); 655 728 const filterTagsDragStartYRef = useRef(0); 656 729 const filterTagsDragStartHeightRef = useRef(0); 657 730 ··· 705 778 // Filter tags drag-to-resize handlers 706 779 const handleFilterTagsDragStart = (clientY: number) => { 707 780 filterTagsDraggingRef.current = true; 781 + isResizingRef.current = true; 708 782 filterTagsDragStartYRef.current = clientY; 709 783 filterTagsDragStartHeightRef.current = filterTagsRef.current?.offsetHeight ?? filterTagsHeight; 710 784 document.body.style.userSelect = 'none'; ··· 725 799 const handleEnd = () => { 726 800 if (!filterTagsDraggingRef.current) return; 727 801 filterTagsDraggingRef.current = false; 802 + isResizingRef.current = false; 728 803 document.body.style.userSelect = ''; 729 804 document.body.style.cursor = ''; 730 805 // Persist to localStorage ··· 742 817 document.removeEventListener('touchend', handleEnd); 743 818 }; 744 819 }, [filterTagsHeight]); 820 + 821 + // Persist search text to localStorage 822 + useEffect(() => { 823 + localStorage.setItem('searchText', searchText); 824 + }, [searchText]); 825 + 826 + // Persist selected filter tags to localStorage 827 + useEffect(() => { 828 + localStorage.setItem('selectedFilterTags', JSON.stringify(Array.from(selectedFilterTags))); 829 + }, [selectedFilterTags]); 830 + 831 + // Persist sort order to localStorage 832 + useEffect(() => { 833 + localStorage.setItem('sortOrder', sortOrder); 834 + }, [sortOrder]); 745 835 746 836 // DEV: random background tint on every load to verify page refreshed 747 837 useEffect(() => { ··· 1733 1823 1734 1824 // Pull-to-refresh touch handlers 1735 1825 const handleTouchStart = (e: React.TouchEvent) => { 1736 - // Ignore if editing, add input expanded, or already syncing 1826 + // Ignore if editing, add input expanded, already syncing, or resizing 1737 1827 const anyEditing = editingUrlId || editingTextId || editingTagsetId || editingImageId; 1738 1828 if (anyEditing || addInputExpanded || isSyncing) return; 1829 + if (isResizingRef.current || document.body.dataset.resizing) return; 1739 1830 1740 1831 const main = mainRef.current; 1741 1832 if (!main) return; ··· 1750 1841 1751 1842 const handleTouchMove = (e: TouchEvent) => { 1752 1843 if (pullStartY.current === null) return; 1844 + 1845 + // Cancel pull if resizing detected 1846 + if (isResizingRef.current || document.body.dataset.resizing) { 1847 + pullStartY.current = null; 1848 + pullThresholdCrossedAt.current = null; 1849 + setPullState('idle'); 1850 + return; 1851 + } 1753 1852 1754 1853 const pullDistance = e.touches[0].clientY - pullStartY.current; 1755 1854 ··· 1910 2009 return matchesSearch && matchesTags; 1911 2010 }); 1912 2011 1913 - // Sort by date, newest first 1914 - return filtered.sort((a, b) => new Date(b.saved_at).getTime() - new Date(a.saved_at).getTime()); 2012 + // Sort by date, respecting sort order 2013 + return filtered.sort((a, b) => { 2014 + const diff = new Date(b.saved_at).getTime() - new Date(a.saved_at).getTime(); 2015 + return sortOrder === 'oldest' ? -diff : diff; 2016 + }); 1915 2017 }; 1916 2018 1917 2019 // Check if any edit mode is active ··· 2201 2303 // Get summary: first line or truncated content (without hashtags for display) 2202 2304 const contentWithoutTags = item.content.replace(/#\w+/g, '').trim(); 2203 2305 const summary = contentWithoutTags.split('\n')[0].slice(0, 100) || item.content.slice(0, 100); 2306 + // Extract first URL from content 2307 + const urlMatch = item.content.match(/https?:\/\/[^\s<>"{}|\\^`[\]]+/); 2204 2308 2205 2309 return ( 2206 2310 <div key={item.id} className="saved-item-card" onClick={() => startEditingText(item)}> ··· 2214 2318 </svg> 2215 2319 </div> 2216 2320 <div className="card-title">{summary}</div> 2321 + {urlMatch && ( 2322 + <button 2323 + className="card-open-url-btn" 2324 + onClick={(e) => { 2325 + e.stopPropagation(); 2326 + openUrl(urlMatch[0]); 2327 + }} 2328 + title="Open URL" 2329 + > 2330 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 2331 + <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path> 2332 + <polyline points="15 3 21 3 21 9"></polyline> 2333 + <line x1="10" y1="14" x2="21" y2="3"></line> 2334 + </svg> 2335 + </button> 2336 + )} 2217 2337 <button 2218 2338 className="card-delete-btn" 2219 2339 onClick={(e) => { ··· 3065 3185 }} 3066 3186 style={{ cursor: "pointer" }} 3067 3187 > 3068 - Peek <span style={{ fontSize: '0.5em', opacity: 0.5 }}>v459</span> 3188 + Peek <span style={{ fontSize: '0.5em', opacity: 0.5 }}>{__BUILD_NUMBER__}</span> 3069 3189 </h1> 3070 3190 <div className="filter-icons"> 3071 3191 <button ··· 3117 3237 <span className="filter-count">{savedImages.length}</span> 3118 3238 </button> 3119 3239 </div> 3240 + <button 3241 + className="sort-btn" 3242 + onClick={() => setSortOrder(prev => prev === 'newest' ? 'oldest' : 'newest')} 3243 + title={sortOrder === 'newest' ? 'Newest first' : 'Oldest first'} 3244 + > 3245 + {sortOrder === 'newest' ? ( 3246 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 3247 + <line x1="12" y1="5" x2="12" y2="19"></line> 3248 + <polyline points="19 12 12 19 5 12"></polyline> 3249 + </svg> 3250 + ) : ( 3251 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 3252 + <line x1="12" y1="19" x2="12" y2="5"></line> 3253 + <polyline points="5 12 12 5 19 12"></polyline> 3254 + </svg> 3255 + )} 3256 + </button> 3120 3257 <button className={`header-btn settings-btn ${isSyncing ? 'syncing' : ''}`} onClick={() => setShowSettings(true)}> 3121 3258 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 3122 3259 <circle cx="12" cy="12" r="3"></circle>
+12
backend/tauri-mobile/vite.config.ts
··· 1 1 import { defineConfig } from "vite"; 2 2 import react from "@vitejs/plugin-react"; 3 + import { execSync } from "child_process"; 3 4 4 5 // @ts-expect-error process is a nodejs global 5 6 const host = process.env.TAURI_DEV_HOST; 6 7 // @ts-expect-error process is a nodejs global 7 8 const port = parseInt(process.env.DEV_PORT, 10) || 1420; 8 9 10 + const buildNumber = (() => { 11 + try { 12 + return execSync("git rev-list --count HEAD").toString().trim(); 13 + } catch { 14 + return "0"; 15 + } 16 + })(); 17 + 9 18 // https://vite.dev/config/ 10 19 export default defineConfig(async () => ({ 11 20 plugins: [react()], 21 + define: { 22 + __BUILD_NUMBER__: JSON.stringify(`v${buildNumber}`), 23 + }, 12 24 13 25 // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 14 26 //