diff --git a/apps/mobile/app/(authenticated)/_layout.tsx b/apps/mobile/app/(authenticated)/_layout.tsx index 7a8f09ff7ff..421bc09dd9d 100644 --- a/apps/mobile/app/(authenticated)/_layout.tsx +++ b/apps/mobile/app/(authenticated)/_layout.tsx @@ -67,7 +67,7 @@ export default function AuthenticatedLayout() { = + JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")) + .patchedDependencies ?? {}; +const lockfile = readFileSync(join(repoRoot, "bun.lock"), "utf8"); + +describe("expo-observe swift version patch", () => { + test("every resolved expo-observe version is patched", () => { + const resolved = [ + ...lockfile.matchAll(/"expo-observe": \["expo-observe@([^"]+)"/g), + ].map((match) => `expo-observe@${match[1]}`); + + expect(resolved.length).toBeGreaterThan(0); + for (const version of resolved) { + expect(patched[version]).toBeString(); + } + }); + + test("the patch still drops the podspec out of Swift 6", () => { + for (const path of Object.entries(patched) + .filter(([name]) => name.startsWith("expo-observe@")) + .map(([, file]) => file)) { + const patch = readFileSync(join(repoRoot, path), "utf8"); + expect(patch).toContain("ios/ExpoObserve.podspec"); + expect(patch).toContain("- s.swift_version = '6.0'"); + expect(patch).toContain("+ s.swift_version = '5.0'"); + } + }); + + test("the installed package carries the patch", () => { + const podspec = readFileSync( + join( + import.meta.dir, + "node_modules/expo-observe/ios/ExpoObserve.podspec", + ), + "utf8", + ); + expect(podspec).toContain("s.swift_version = '5.0'"); + }); +}); diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index af38d08e704..2433acc2bcd 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -35,6 +35,7 @@ config.resolver.unstable_enablePackageExports = true; config.resolver.extraNodeModules = { "@superset/alert-prompt": path.resolve(projectRoot, "modules/alert-prompt"), "@superset/composer": path.resolve(projectRoot, "modules/composer"), + "@superset/symbol-button": path.resolve(projectRoot, "modules/symbol-button"), "@superset/attachments-sheet": path.resolve( projectRoot, "modules/attachments-sheet", diff --git a/apps/mobile/modules/symbol-button/expo-module.config.json b/apps/mobile/modules/symbol-button/expo-module.config.json new file mode 100644 index 00000000000..2e4254da02e --- /dev/null +++ b/apps/mobile/modules/symbol-button/expo-module.config.json @@ -0,0 +1,4 @@ +{ + "platforms": ["apple"], + "apple": { "modules": ["SymbolButtonModule"] } +} diff --git a/apps/mobile/modules/symbol-button/ios/SymbolButton.podspec b/apps/mobile/modules/symbol-button/ios/SymbolButton.podspec new file mode 100644 index 00000000000..1eb22a4edc6 --- /dev/null +++ b/apps/mobile/modules/symbol-button/ios/SymbolButton.podspec @@ -0,0 +1,25 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) + +Pod::Spec.new do |s| + s.name = 'SymbolButton' + s.version = package['version'] + s.summary = 'SF Symbol button with an optional anchored menu' + s.description = 'A UIButton rendering an SF Symbol, which shows a UIMenu anchored to itself when given items' + s.license = 'MIT' + s.author = 'Superset' + s.homepage = 'https://superset.sh' + s.platforms = { :ios => '26.0' } + s.swift_version = '5.9' + s.source = { git: 'https://github.com/superset-sh/superset.git' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES' + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/apps/mobile/modules/symbol-button/ios/SymbolButtonModule.swift b/apps/mobile/modules/symbol-button/ios/SymbolButtonModule.swift new file mode 100644 index 00000000000..946efb80f25 --- /dev/null +++ b/apps/mobile/modules/symbol-button/ios/SymbolButtonModule.swift @@ -0,0 +1,117 @@ +import ExpoModulesCore +import UIKit + +struct SymbolMenuItem: Record { + @Field var id: String = "" + @Field var title: String = "" + @Field var systemImage: String? +} + +public class SymbolButtonModule: Module { + public func definition() -> ModuleDefinition { + Name("SymbolButton") + + View(SymbolButtonView.self) { + Events("onTap", "onSelect") + + Prop("systemImage") { (view: SymbolButtonView, name: String) in + view.setSymbol(name) + } + + Prop("size") { (view: SymbolButtonView, size: Double) in + view.setSymbolSize(CGFloat(size)) + } + + Prop("tint") { (view: SymbolButtonView, color: UIColor?) in + view.setTint(color) + } + + Prop("accessibilityLabel") { (view: SymbolButtonView, label: String?) in + view.button.accessibilityLabel = label + } + + Prop("enabled") { (view: SymbolButtonView, enabled: Bool) in + view.button.isEnabled = enabled + } + + Prop("items") { (view: SymbolButtonView, items: [SymbolMenuItem]?) in + view.setItems(items ?? []) + } + } + } +} + +final class SymbolButtonView: ExpoView { + let button = UIButton(type: .system) + + private let onTap = EventDispatcher() + private let onSelect = EventDispatcher() + + private var symbolName = "circle" + private var symbolSize: CGFloat = 20 + private var items: [SymbolMenuItem] = [] + + required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + + button.translatesAutoresizingMaskIntoConstraints = false + button.addTarget(self, action: #selector(handleTap), for: .touchUpInside) + addSubview(button) + + isAccessibilityElement = false + + NSLayoutConstraint.activate([ + button.leadingAnchor.constraint(equalTo: leadingAnchor), + button.trailingAnchor.constraint(equalTo: trailingAnchor), + button.topAnchor.constraint(equalTo: topAnchor), + button.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + applySymbol() + } + + func setSymbol(_ name: String) { + symbolName = name + applySymbol() + } + + func setSymbolSize(_ size: CGFloat) { + symbolSize = size + applySymbol() + } + + func setTint(_ color: UIColor?) { + button.tintColor = color + } + + func setItems(_ items: [SymbolMenuItem]) { + self.items = items + guard !items.isEmpty else { + button.menu = nil + button.showsMenuAsPrimaryAction = false + return + } + + let actions = items.map { item in + UIAction( + title: item.title, + image: item.systemImage.flatMap { UIImage(systemName: $0) } + ) { [weak self] _ in + self?.onSelect(["id": item.id]) + } + } + + button.menu = UIMenu(children: actions) + button.showsMenuAsPrimaryAction = true + } + + @objc private func handleTap() { + guard items.isEmpty else { return } + onTap() + } + + private func applySymbol() { + let config = UIImage.SymbolConfiguration(pointSize: symbolSize, weight: .regular) + button.setImage(UIImage(systemName: symbolName, withConfiguration: config), for: .normal) + } +} diff --git a/apps/mobile/modules/symbol-button/package.json b/apps/mobile/modules/symbol-button/package.json new file mode 100644 index 00000000000..a388ab321b5 --- /dev/null +++ b/apps/mobile/modules/symbol-button/package.json @@ -0,0 +1,11 @@ +{ + "name": "@superset/symbol-button", + "version": "0.0.1", + "main": "src/index.tsx", + "private": true, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } +} diff --git a/apps/mobile/modules/symbol-button/src/index.tsx b/apps/mobile/modules/symbol-button/src/index.tsx new file mode 100644 index 00000000000..762eace7447 --- /dev/null +++ b/apps/mobile/modules/symbol-button/src/index.tsx @@ -0,0 +1,39 @@ +import { requireNativeView } from "expo"; +import type { StyleProp, ViewStyle } from "react-native"; + +export interface SymbolMenuItem { + id: string; + title: string; + systemImage?: string; +} + +interface SymbolButtonProps { + systemImage: string; + size?: number; + tint?: string; + accessibilityLabel?: string; + enabled?: boolean; + items?: SymbolMenuItem[]; + onTap?: () => void; + onSelect?: (event: { nativeEvent: { id: string } }) => void; + style?: StyleProp; +} + +const NativeSymbolButton = requireNativeView("SymbolButton"); + +export function SymbolButton({ + onSelect, + onPress, + ...props +}: Omit & { + onSelect?: (id: string) => void; + onPress?: () => void; +}) { + return ( + onSelect?.(event.nativeEvent.id)} + /> + ); +} diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/PageDetailScreen.tsx b/apps/mobile/screens/(authenticated)/pages/[slug]/PageDetailScreen.tsx index f1a6084018f..21f0fb5b1aa 100644 --- a/apps/mobile/screens/(authenticated)/pages/[slug]/PageDetailScreen.tsx +++ b/apps/mobile/screens/(authenticated)/pages/[slug]/PageDetailScreen.tsx @@ -1,9 +1,6 @@ -import type { MessageDescriptor } from "@lingui/core"; import { useLingui } from "@lingui/react/macro"; -import { usePageComments, usePageCommentThreads } from "@superset/cloud-client"; -import { i18n } from "@superset/i18n"; +import { usePageCommentThreads } from "@superset/cloud-client"; import { getInitials } from "@superset/shared/names"; -import type { CommentIntent } from "@superset/shared/page-comments"; import { type CommentAnchor, type FrameMessage, @@ -17,17 +14,16 @@ import { useLocalSearchParams, useRouter, } from "expo-router"; +import { useHeaderHeight } from "expo-router/react-navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Alert, View } from "react-native"; +import { View } from "react-native"; import { Spinner } from "@/components/ui/spinner"; import { Text } from "@/components/ui/text"; import { errorCopy } from "@/lib/errors"; import { PressableScale } from "@/screens/(authenticated)/components/PressableScale"; import { usePageQuery } from "../hooks/usePages"; import { CommentPin } from "./components/CommentPin"; -import { CommentPopover } from "./components/CommentPopover"; import { PageFrame, type PageFrameHandle } from "./components/PageFrame"; -import { usePageCommentUser } from "./hooks/usePageCommentUser"; import { usePageCommentStore } from "./stores/pageCommentStore"; import { pinPointOf, stackPins } from "./utils/pinLayout"; @@ -68,6 +64,7 @@ export function PageDetailScreen({ slug: string; scrollY?: string; }>(); + const headerHeight = useHeaderHeight(); const frameRef = useRef(null); const scrollYRef = useRef(0); const restoredScroll = useRef(false); @@ -77,11 +74,11 @@ export function PageDetailScreen({ const [frameEpoch, setFrameEpoch] = useState(0); const [commentMode, setCommentMode] = useState(false); const [selection, setSelection] = useState(null); - const rememberPickRef = useRef<(anchor: CommentAnchor) => void>(() => {}); - const dismissSelectionRef = useRef<() => void>(() => {}); + const startCommentRef = useRef<(anchor: CommentAnchor) => boolean>( + () => false, + ); const selectionRef = useRef(selection); selectionRef.current = selection; - const submittingRef = useRef(false); const [rects, setRects] = useState>({}); const page = usePageQuery(slug); @@ -101,17 +98,6 @@ export function PageDetailScreen({ const setFocusThreadId = usePageCommentStore( (state) => state.setFocusThreadId, ); - const user = usePageCommentUser(); - const store = usePageComments({ - pageId: pageId ?? "", - version: version ?? 0, - user, - }); - const [overlay, setOverlay] = useState<{ - width: number; - height: number; - } | null>(null); - submittingRef.current = store.submitting; const unresolvedThreads = useMemo( () => threads.filter((thread) => !thread.resolved), @@ -155,9 +141,10 @@ export function PageDetailScreen({ useFocusEffect( useCallback(() => { setFrameEpoch((epoch) => epoch + 1); - if (!usePageCommentStore.getState().anchor) setSelection(null); + setSelection(null); + clearPick(); refetchComments(); - }, [refetchComments]), + }, [clearPick, refetchComments]), ); useEffect(() => { @@ -178,16 +165,13 @@ export function PageDetailScreen({ } setRects((previous) => (sameRects(previous, next) ? previous : next)); } - if (message.type === "pointer-down" && !submittingRef.current) { - dismissSelectionRef.current(); - } if (message.type === "pick") { if (!selectionRef.current) { + if (!startCommentRef.current(message.anchor)) return; const next = { anchor: message.anchor, rect: message.rect }; selectionRef.current = next; void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); setSelection(next); - rememberPickRef.current(message.anchor); } } }, []); @@ -208,12 +192,17 @@ export function PageDetailScreen({ [pins], ); - rememberPickRef.current = useCallback( + startCommentRef.current = useCallback( (anchor: CommentAnchor) => { - if (!pageId || !version) return; + if (!pageId || version === undefined) return false; setPick({ pageId, version, anchor }); + router.push({ + pathname: "/(authenticated)/pages/[slug]/comment", + params: { slug }, + }); + return true; }, - [pageId, setPick, version], + [pageId, router, setPick, slug, version], ); const openThread = useCallback( @@ -228,39 +217,6 @@ export function PageDetailScreen({ [router, setFocusThreadId, slug], ); - const dismissSelection = useCallback(() => { - setSelection(null); - clearPick(); - }, [clearPick]); - dismissSelectionRef.current = dismissSelection; - - const createAnchored = useCallback( - async (body: string, intent?: CommentIntent) => { - const anchor = selectionRef.current?.anchor; - if (!anchor) return; - await store.createThread({ - anchor, - anchorText: anchor.text, - body, - ...(intent ? { intent } : {}), - }); - dismissSelection(); - }, - [dismissSelection, store], - ); - - const postQuick = useCallback( - async (body: MessageDescriptor, intent: CommentIntent) => { - if (store.submitting) return; - try { - await createAnchored(i18n._(body), intent); - } catch (error) { - Alert.alert(t({ message: "Comment not posted" }), errorCopy(error)); - } - }, - [createAnchored, store.submitting, t], - ); - const retryFrame = useCallback(async () => { setFailedSrc(null); setLoadedSrc(null); @@ -270,10 +226,6 @@ export function PageDetailScreen({ return ( - - {presentation === "sheet" ? ( { setLoadedSrc(viewUrl); @@ -368,16 +321,9 @@ export function PageDetailScreen({ /> { - const { width, height } = event.nativeEvent.layout; - setOverlay((previous) => - previous?.width === width && previous?.height === height - ? previous - : { width, height }, - ); - }} > {unresolvedThreads.map((thread) => { const point = pinPoints.get(thread.id); @@ -407,23 +353,6 @@ export function PageDetailScreen({ className="absolute rounded-sm border border-blue-500 bg-blue-500/10" /> ) : null} - - {selection && selectionRect && overlay ? ( - - router.push({ - pathname: "/(authenticated)/pages/[slug]/quick", - params: { slug }, - }) - } - onQuick={(body, intent) => void postQuick(body, intent)} - onSubmit={(body) => createAnchored(body)} - /> - ) : null} {commentMode && !selection ? ( diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/comment/CommentSheet.tsx b/apps/mobile/screens/(authenticated)/pages/[slug]/comment/CommentSheet.tsx new file mode 100644 index 00000000000..d30681677d3 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/pages/[slug]/comment/CommentSheet.tsx @@ -0,0 +1,107 @@ +import type { MessageDescriptor } from "@lingui/core"; +import { useLingui } from "@lingui/react/macro"; +import { usePageComments } from "@superset/cloud-client"; +import { i18n } from "@superset/i18n"; +import type { CommentIntent } from "@superset/shared/page-comments"; +import { useNavigation, useRouter } from "expo-router"; +import { useEffect, useRef, useState } from "react"; +import { Alert, Pressable, View } from "react-native"; +import Animated, { + useAnimatedKeyboard, + useAnimatedStyle, +} from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { errorCopy } from "@/lib/errors"; +import { CommentComposer } from "../components/CommentComposer"; +import { ComposerCard } from "../components/ComposerCard"; +import { QuickReplies } from "../components/QuickReplies"; +import { usePageCommentUser } from "../hooks/usePageCommentUser"; +import { usePageCommentStore } from "../stores/pageCommentStore"; + +export function CommentSheet() { + const { t } = useLingui(); + const router = useRouter(); + const navigation = useNavigation(); + const [pick] = useState(() => { + const state = usePageCommentStore.getState(); + return { + pageId: state.pageId, + version: state.version, + anchor: state.anchor, + }; + }); + const user = usePageCommentUser(); + const insets = useSafeAreaInsets(); + const keyboard = useAnimatedKeyboard(); + const lift = useAnimatedStyle(() => ({ + paddingBottom: Math.max(keyboard.height.value, insets.bottom) + 8, + })); + const store = usePageComments({ + pageId: pick.pageId ?? "", + version: pick.version ?? 0, + user, + }); + const inFlight = useRef(false); + + useEffect(() => { + if (!pick.anchor) router.back(); + }, [pick.anchor, router]); + + const post = async (text: string, intent?: CommentIntent) => { + const { anchor, version } = pick; + if (!anchor || version === null || inFlight.current) { + throw new Error(t({ message: "Try again" })); + } + inFlight.current = true; + try { + await store.createThread({ + anchor, + anchorText: anchor.text, + body: text, + ...(intent ? { intent } : {}), + }); + } finally { + inFlight.current = false; + } + if (navigation.isFocused()) router.back(); + }; + + const postQuick = async (text: string, intent?: CommentIntent) => { + if (inFlight.current) return; + try { + await post(text, intent); + } catch (error) { + Alert.alert(t({ message: "Comment not posted" }), errorCopy(error)); + } + }; + + return ( + + router.back()} + /> + + + post(body)} + actions={({ hasDraft }) => ( + { + void postQuick(i18n._(quick), intent); + }} + onPreset={(preset) => void postQuick(preset)} + /> + )} + /> + + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/comment/index.ts b/apps/mobile/screens/(authenticated)/pages/[slug]/comment/index.ts new file mode 100644 index 00000000000..fd10297e3e0 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/pages/[slug]/comment/index.ts @@ -0,0 +1 @@ +export { CommentSheet } from "./CommentSheet"; diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/comments/AllCommentsSheet.tsx b/apps/mobile/screens/(authenticated)/pages/[slug]/comments/AllCommentsSheet.tsx index ce123b82f8e..2dea15c8443 100644 --- a/apps/mobile/screens/(authenticated)/pages/[slug]/comments/AllCommentsSheet.tsx +++ b/apps/mobile/screens/(authenticated)/pages/[slug]/comments/AllCommentsSheet.tsx @@ -22,8 +22,6 @@ const VISIBLE_REPLIES = 2; interface ReplyTarget { threadId: string; - name: string; - excerpt: string; } type PendingScroll = "end" | { threadId: string; atBottom: boolean }; @@ -82,22 +80,14 @@ export function AllCommentsSheet() { if (!thread) return; if (thread.resolved) setShowResolved(true); setExpanded((previous) => ({ ...previous, [thread.id]: true })); - setReplyingTo({ - threadId: thread.id, - name: thread.comments[0]?.authorName ?? "", - excerpt: thread.comments[0]?.body ?? "", - }); + setReplyingTo({ threadId: thread.id }); pendingScroll.current = { threadId: thread.id, atBottom: false }; setFocusThreadId(null); }, [focusThreadId, threads, setFocusThreadId]); const startReply = (thread: ServerThread) => { setExpanded((previous) => ({ ...previous, [thread.id]: true })); - setReplyingTo({ - threadId: thread.id, - name: thread.comments[0]?.authorName ?? "", - excerpt: thread.comments[0]?.body ?? "", - }); + setReplyingTo({ threadId: thread.id }); scrollToThread(thread.id); composerRef.current?.focus(); }; @@ -119,7 +109,6 @@ export function AllCommentsSheet() { if (!replyingTo) return; const { threadId } = replyingTo; setExpanded((previous) => ({ ...previous, [threadId]: true })); - setReplyingTo(null); pendingScroll.current = { threadId, atBottom: true }; try { await store.addReply(threadId, body); @@ -127,6 +116,7 @@ export function AllCommentsSheet() { pendingScroll.current = null; throw error; } + setReplyingTo((current) => (current === replyingTo ? null : current)); }; return ( @@ -141,7 +131,7 @@ export function AllCommentsSheet() { setReplyingTo(null)} onSubmit={submit} /> ) : null} diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/comments/components/ReplyBar/ReplyBar.tsx b/apps/mobile/screens/(authenticated)/pages/[slug]/comments/components/ReplyBar/ReplyBar.tsx index c8f0c0bfef8..e59c868563f 100644 --- a/apps/mobile/screens/(authenticated)/pages/[slug]/comments/components/ReplyBar/ReplyBar.tsx +++ b/apps/mobile/screens/(authenticated)/pages/[slug]/comments/components/ReplyBar/ReplyBar.tsx @@ -1,79 +1,42 @@ import { useLingui } from "@lingui/react/macro"; -import { X } from "lucide-react-native"; import { forwardRef } from "react"; -import { Pressable, View } from "react-native"; import Animated, { useAnimatedKeyboard, useAnimatedStyle, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Icon } from "@/components/ui/icon"; -import { Text } from "@/components/ui/text"; import { CommentComposer, type CommentComposerHandle, } from "../../../components/CommentComposer"; +import { ComposerCard } from "../../../components/ComposerCard"; interface ReplyBarProps { - replyingTo: string | null; - excerpt?: string; pending: boolean; - onCancelReply: () => void; onSubmit: (body: string) => Promise; } export const ReplyBar = forwardRef( - function ReplyBar( - { replyingTo, excerpt, pending, onCancelReply, onSubmit }, - ref, - ) { + function ReplyBar({ pending, onSubmit }, ref) { const { t } = useLingui(); const insets = useSafeAreaInsets(); const keyboard = useAnimatedKeyboard(); const lift = useAnimatedStyle(() => ({ - paddingBottom: Math.max(keyboard.height.value, insets.bottom), + paddingBottom: Math.max(keyboard.height.value, insets.bottom) + 8, })); return ( - - {replyingTo ? ( - - - - {t({ message: `Replying to ${replyingTo}` })} - - {excerpt ? ( - - {excerpt} - - ) : null} - - - - - - ) : null} - - + + + + ); }, diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/components/CommentComposer/CommentComposer.tsx b/apps/mobile/screens/(authenticated)/pages/[slug]/components/CommentComposer/CommentComposer.tsx index 8c64ff353d0..0762540d136 100644 --- a/apps/mobile/screens/(authenticated)/pages/[slug]/components/CommentComposer/CommentComposer.tsx +++ b/apps/mobile/screens/(authenticated)/pages/[slug]/components/CommentComposer/CommentComposer.tsx @@ -1,13 +1,16 @@ import { useLingui } from "@lingui/react/macro"; -import { getInitials } from "@superset/shared/names"; import * as Haptics from "expo-haptics"; -import { Image } from "expo-image"; import { ArrowUp } from "lucide-react-native"; -import { forwardRef, useImperativeHandle, useRef, useState } from "react"; +import { + forwardRef, + type ReactNode, + useImperativeHandle, + useRef, + useState, +} from "react"; import { Alert, Pressable, TextInput, View } from "react-native"; import { Icon } from "@/components/ui/icon"; -import { Text } from "@/components/ui/text"; -import { useSession } from "@/lib/auth/client"; +import { useTheme } from "@/hooks/useTheme"; import { errorCopy } from "@/lib/errors"; import { cn } from "@/lib/utils"; @@ -19,6 +22,7 @@ interface CommentComposerProps { placeholder: string; autoFocus?: boolean; pending?: boolean; + actions?: (state: { hasDraft: boolean }) => ReactNode; onSubmit: (body: string) => Promise; } @@ -26,12 +30,13 @@ export const CommentComposer = forwardRef< CommentComposerHandle, CommentComposerProps >(function CommentComposer( - { placeholder, autoFocus = false, pending = false, onSubmit }, + { placeholder, autoFocus = false, pending = false, actions, onSubmit }, ref, ) { const { t } = useLingui(); - const { data: session } = useSession(); + const theme = useTheme(); const inputRef = useRef(null); + const inFlight = useRef(false); const [body, setBody] = useState(""); const trimmed = body.trim(); const canSend = trimmed.length > 0 && !pending; @@ -41,43 +46,38 @@ export const CommentComposer = forwardRef< })); const send = async () => { - if (!canSend) return; + if (!canSend || inFlight.current) return; + inFlight.current = true; void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); try { await onSubmit(trimmed); setBody(""); } catch (error) { Alert.alert(t({ message: "Comment not posted" }), errorCopy(error)); + } finally { + inFlight.current = false; } }; return ( - - - {session?.user.image ? ( - - ) : ( - - {getInitials(session?.user.name) || "?"} - - )} - + + + + + + {actions?.({ hasDraft: trimmed.length > 0 })} + - - void send()} hitSlop={6} className={cn( - "size-7 shrink-0 items-center justify-center rounded-full", - canSend ? "bg-primary" : "bg-muted", + "size-8 shrink-0 items-center justify-center rounded-full", + canSend ? "bg-primary" : "bg-foreground/15", )} > void; - onOpenPresets: () => void; - onDismiss: () => void; - onSubmit: (body: string) => Promise; -} - -export function CommentPopover({ - rect, - container, - pending, - onQuick, - onOpenPresets, - onDismiss, - onSubmit, -}: CommentPopoverProps) { - const { t } = useLingui(); - const [height, setHeight] = useState(ESTIMATED_HEIGHT); - const [keyboardHeight, setKeyboardHeight] = useState(0); - - useEffect(() => { - const shown = Keyboard.addListener("keyboardWillShow", (event) => - setKeyboardHeight(event.endCoordinates.height), - ); - const hidden = Keyboard.addListener("keyboardWillHide", () => - setKeyboardHeight(0), - ); - return () => { - shown.remove(); - hidden.remove(); - }; - }, []); - - // The keyboard takes the bottom of the container away rather than sliding - // the card over what it is anchored to: placing against what is left lets - // the card flip above the block, which pinSize sizes to clear. - const { left, top, width } = popoverPlacement({ - point: { x: rect.left, y: rect.top + rect.height }, - container: { - width: container.width, - height: container.height - keyboardHeight, - }, - height, - pinSize: rect.height, - maxWidth: container.width, - }); - - const settle = useAnimatedStyle(() => ({ - top: withTiming(top, { duration: KEYBOARD_MS }), - })); - - return ( - setHeight(event.nativeEvent.layout.height)} - className="bg-popover rounded-2xl px-3 py-2 shadow-xl" - > - - - - - - - - - - ); -} diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/components/CommentPopover/index.ts b/apps/mobile/screens/(authenticated)/pages/[slug]/components/CommentPopover/index.ts deleted file mode 100644 index 98288a902c0..00000000000 --- a/apps/mobile/screens/(authenticated)/pages/[slug]/components/CommentPopover/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CommentPopover } from "./CommentPopover"; diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/components/CommentRow/CommentRow.tsx b/apps/mobile/screens/(authenticated)/pages/[slug]/components/CommentRow/CommentRow.tsx index 78dc84f91df..5c03f84db87 100644 --- a/apps/mobile/screens/(authenticated)/pages/[slug]/components/CommentRow/CommentRow.tsx +++ b/apps/mobile/screens/(authenticated)/pages/[slug]/components/CommentRow/CommentRow.tsx @@ -1,6 +1,6 @@ import { useLingui } from "@lingui/react/macro"; import type { ServerThread } from "@superset/cloud-client"; -import { formatDate } from "@superset/i18n/format"; +import { formatCompactRelativeTime } from "@superset/i18n/format"; import { getInitials } from "@superset/shared/names"; import { commentAuthor } from "@superset/shared/page-comments"; import { Bot } from "lucide-react-native"; @@ -48,7 +48,7 @@ export function CommentRow({ {name} - {formatDate(comment.createdAt)} + {formatCompactRelativeTime(comment.createdAt)} {comment.body} diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/components/ComposerActions/ComposerActions.tsx b/apps/mobile/screens/(authenticated)/pages/[slug]/components/ComposerActions/ComposerActions.tsx deleted file mode 100644 index 4774babbc1d..00000000000 --- a/apps/mobile/screens/(authenticated)/pages/[slug]/components/ComposerActions/ComposerActions.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type { MessageDescriptor } from "@lingui/core"; -import { useLingui } from "@lingui/react/macro"; -import type { CommentIntent } from "@superset/shared/page-comments"; -import { ThumbsUp, Trash2, Zap } from "lucide-react-native"; -import { View } from "react-native"; -import { APPROVE_BODY, DELETE_BODY } from "../CommentComposer/constants"; -import { ComposerActionButton } from "./components/ComposerActionButton"; - -interface ComposerActionsProps { - disabled?: boolean; - onQuick: (body: MessageDescriptor, intent: CommentIntent) => void; - onOpenPresets: () => void; -} - -export function ComposerActions({ - disabled = false, - onQuick, - onOpenPresets, -}: ComposerActionsProps) { - const { t } = useLingui(); - - return ( - - onQuick(DELETE_BODY, "delete")} - /> - - onQuick(APPROVE_BODY, "approve")} - /> - - ); -} diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/components/ComposerActions/components/ComposerActionButton/ComposerActionButton.tsx b/apps/mobile/screens/(authenticated)/pages/[slug]/components/ComposerActions/components/ComposerActionButton/ComposerActionButton.tsx deleted file mode 100644 index 5f28f010f4a..00000000000 --- a/apps/mobile/screens/(authenticated)/pages/[slug]/components/ComposerActions/components/ComposerActionButton/ComposerActionButton.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import * as Haptics from "expo-haptics"; -import type { LucideIcon } from "lucide-react-native"; -import { Pressable } from "react-native"; -import { Icon } from "@/components/ui/icon"; - -interface ComposerActionButtonProps { - icon: LucideIcon; - label: string; - disabled: boolean; - onPress: () => void; -} - -export function ComposerActionButton({ - icon, - label, - disabled, - onPress, -}: ComposerActionButtonProps) { - return ( - { - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - onPress(); - }} - className={ - disabled - ? "size-10 items-center justify-center rounded-lg opacity-50" - : "size-10 items-center justify-center rounded-lg active:bg-accent" - } - > - - - ); -} diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/components/ComposerActions/components/ComposerActionButton/index.ts b/apps/mobile/screens/(authenticated)/pages/[slug]/components/ComposerActions/components/ComposerActionButton/index.ts deleted file mode 100644 index 2216a563bf7..00000000000 --- a/apps/mobile/screens/(authenticated)/pages/[slug]/components/ComposerActions/components/ComposerActionButton/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ComposerActionButton } from "./ComposerActionButton"; diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/components/ComposerActions/index.ts b/apps/mobile/screens/(authenticated)/pages/[slug]/components/ComposerActions/index.ts deleted file mode 100644 index 150a2d42214..00000000000 --- a/apps/mobile/screens/(authenticated)/pages/[slug]/components/ComposerActions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ComposerActions } from "./ComposerActions"; diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/components/ComposerCard/ComposerCard.tsx b/apps/mobile/screens/(authenticated)/pages/[slug]/components/ComposerCard/ComposerCard.tsx new file mode 100644 index 00000000000..cd29b622d48 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/pages/[slug]/components/ComposerCard/ComposerCard.tsx @@ -0,0 +1,22 @@ +import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; +import type { ReactNode } from "react"; +import { View } from "react-native"; + +export function ComposerCard({ children }: { children: ReactNode }) { + const content = {children}; + if (isLiquidGlassAvailable()) { + return ( + + {content} + + ); + } + return ( + + {content} + + ); +} diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/components/ComposerCard/index.ts b/apps/mobile/screens/(authenticated)/pages/[slug]/components/ComposerCard/index.ts new file mode 100644 index 00000000000..41d8b74aa7c --- /dev/null +++ b/apps/mobile/screens/(authenticated)/pages/[slug]/components/ComposerCard/index.ts @@ -0,0 +1 @@ +export { ComposerCard } from "./ComposerCard"; diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/components/PageFrame/PageFrame.tsx b/apps/mobile/screens/(authenticated)/pages/[slug]/components/PageFrame/PageFrame.tsx index dac7e671d9d..8b02e51d870 100644 --- a/apps/mobile/screens/(authenticated)/pages/[slug]/components/PageFrame/PageFrame.tsx +++ b/apps/mobile/screens/(authenticated)/pages/[slug]/components/PageFrame/PageFrame.tsx @@ -26,6 +26,7 @@ export interface PageFrameHandle { interface PageFrameProps { src: string; + insetTop: number; onMessage: (message: FrameMessage) => void; onLoadEnd: () => void; onError: () => void; @@ -40,7 +41,7 @@ function sameOrigin(url: string, src: string): boolean { } export const PageFrame = forwardRef( - function PageFrame({ src, onMessage, onLoadEnd, onError }, ref) { + function PageFrame({ src, insetTop, onMessage, onLoadEnd, onError }, ref) { const webViewRef = useRef(null); const openLink = useOpenLink(); @@ -75,6 +76,9 @@ export const PageFrame = forwardRef( onLoadEnd={onLoadEnd} onError={onError} onHttpError={onError} + contentInset={{ top: insetTop, left: 0, right: 0, bottom: 0 }} + contentInsetAdjustmentBehavior="never" + automaticallyAdjustContentInsets={false} onShouldStartLoadWithRequest={(request) => { if (!request.isTopFrame) return true; if (sameOrigin(request.url, src)) return true; diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/components/QuickReplies/QuickReplies.tsx b/apps/mobile/screens/(authenticated)/pages/[slug]/components/QuickReplies/QuickReplies.tsx new file mode 100644 index 00000000000..911e2caed1a --- /dev/null +++ b/apps/mobile/screens/(authenticated)/pages/[slug]/components/QuickReplies/QuickReplies.tsx @@ -0,0 +1,104 @@ +import type { MessageDescriptor } from "@lingui/core"; +import { useLingui } from "@lingui/react/macro"; +import { i18n } from "@superset/i18n"; +import type { CommentIntent } from "@superset/shared/page-comments"; +import { SymbolButton } from "@superset/symbol-button"; +import * as Haptics from "expo-haptics"; +import { View } from "react-native"; +import { useTheme } from "@/hooks/useTheme"; +import { APPROVE_BODY, DELETE_BODY, QUICK_PRESETS } from "./constants"; + +const GLYPH = 12; +const DOTS = 14; +const HIT = 40; + +interface QuickRepliesProps { + disabled: boolean; + onQuick: (body: MessageDescriptor, intent: CommentIntent) => void; + onPreset: (body: string) => void; +} + +export function QuickReplies({ + disabled, + onQuick, + onPreset, +}: QuickRepliesProps) { + const { t } = useLingui(); + const theme = useTheme(); + const tap = () => void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + const hit = { + position: "absolute" as const, + top: 0, + left: 0, + width: HIT, + height: HIT, + }; + + return ( + + + + { + tap(); + onQuick(DELETE_BODY, "delete"); + }} + /> + + + + + { + tap(); + onQuick(APPROVE_BODY, "approve"); + }} + /> + + + + + ({ + id: preset.id, + title: i18n._(preset.body), + systemImage: preset.symbol, + }))} + onSelect={(id) => { + const preset = QUICK_PRESETS.find((entry) => entry.id === id); + if (!preset) return; + tap(); + onPreset(i18n._(preset.body)); + }} + /> + + + ); +} diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/components/CommentComposer/constants.ts b/apps/mobile/screens/(authenticated)/pages/[slug]/components/QuickReplies/constants.ts similarity index 52% rename from apps/mobile/screens/(authenticated)/pages/[slug]/components/CommentComposer/constants.ts rename to apps/mobile/screens/(authenticated)/pages/[slug]/components/QuickReplies/constants.ts index 9bcd1b1876a..811e86b87d5 100644 --- a/apps/mobile/screens/(authenticated)/pages/[slug]/components/CommentComposer/constants.ts +++ b/apps/mobile/screens/(authenticated)/pages/[slug]/components/QuickReplies/constants.ts @@ -1,22 +1,9 @@ import type { MessageDescriptor } from "@lingui/core"; import { msg } from "@lingui/core/macro"; -import { - Ban, - Blocks, - CircleQuestionMark, - FlaskConical, - type LucideIcon, - Map as MapIcon, - Search, - Shuffle, - TestTube, - ThumbsUp, - TrendingDown, -} from "lucide-react-native"; export interface QuickPreset { id: string; - icon: LucideIcon; + symbol: string; body: MessageDescriptor; } @@ -26,32 +13,40 @@ export const APPROVE_BODY = msg({ message: "Looks good" }); export const QUICK_PRESETS: QuickPreset[] = [ { id: "clarify", - icon: CircleQuestionMark, + symbol: "questionmark.circle", body: msg({ message: "Clarify this" }), }, - { id: "overview", icon: MapIcon, body: msg({ message: "Missing overview" }) }, - { id: "verify", icon: Search, body: msg({ message: "Verify this" }) }, + { id: "overview", symbol: "map", body: msg({ message: "Missing overview" }) }, + { + id: "verify", + symbol: "magnifyingglass", + body: msg({ message: "Verify this" }), + }, { id: "example", - icon: FlaskConical, + symbol: "flask", body: msg({ message: "Give me an example" }), }, { id: "patterns", - icon: Blocks, + symbol: "square.grid.2x2", body: msg({ message: "Match existing patterns" }), }, { id: "alternatives", - icon: Shuffle, + symbol: "shuffle", body: msg({ message: "Consider alternatives" }), }, { id: "regression", - icon: TrendingDown, + symbol: "chart.line.downtrend.xyaxis", body: msg({ message: "Ensure no regression" }), }, - { id: "scope", icon: Ban, body: msg({ message: "Out of scope" }) }, - { id: "tests", icon: TestTube, body: msg({ message: "Needs tests" }) }, - { id: "approve", icon: ThumbsUp, body: msg({ message: "Nice approach" }) }, + { id: "scope", symbol: "nosign", body: msg({ message: "Out of scope" }) }, + { id: "tests", symbol: "testtube.2", body: msg({ message: "Needs tests" }) }, + { + id: "approve", + symbol: "hand.thumbsup", + body: msg({ message: "Nice approach" }), + }, ]; diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/components/QuickReplies/index.ts b/apps/mobile/screens/(authenticated)/pages/[slug]/components/QuickReplies/index.ts new file mode 100644 index 00000000000..2a041212132 --- /dev/null +++ b/apps/mobile/screens/(authenticated)/pages/[slug]/components/QuickReplies/index.ts @@ -0,0 +1 @@ +export { QuickReplies } from "./QuickReplies"; diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/quick/QuickFeedbackSheet.tsx b/apps/mobile/screens/(authenticated)/pages/[slug]/quick/QuickFeedbackSheet.tsx deleted file mode 100644 index fdb45f1aa80..00000000000 --- a/apps/mobile/screens/(authenticated)/pages/[slug]/quick/QuickFeedbackSheet.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useLingui } from "@lingui/react/macro"; -import { usePageComments } from "@superset/cloud-client"; -import { i18n } from "@superset/i18n"; -import * as Haptics from "expo-haptics"; -import { Stack, useRouter } from "expo-router"; -import { Alert, Pressable, ScrollView, View } from "react-native"; -import { Icon } from "@/components/ui/icon"; -import { Text } from "@/components/ui/text"; -import { errorCopy } from "@/lib/errors"; -import { cn } from "@/lib/utils"; -import { QUICK_PRESETS } from "../components/CommentComposer/constants"; -import { usePageCommentUser } from "../hooks/usePageCommentUser"; -import { usePageCommentStore } from "../stores/pageCommentStore"; - -export function QuickFeedbackSheet() { - const { t } = useLingui(); - const router = useRouter(); - const { pageId, version, anchor } = usePageCommentStore(); - const clear = usePageCommentStore((state) => state.clear); - const user = usePageCommentUser(); - const store = usePageComments({ - pageId: pageId ?? "", - version: version ?? 0, - user, - }); - - const pick = async (body: string) => { - if (!version || !anchor || store.submitting) return; - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - try { - await store.createThread({ anchor, anchorText: anchor.text, body }); - } catch (error) { - Alert.alert(t({ message: "Comment not posted" }), errorCopy(error)); - return; - } - clear(); - router.back(); - }; - - return ( - <> - - router.back()} - /> - - - - {QUICK_PRESETS.map((preset) => { - const label = i18n._(preset.body); - return ( - void pick(label)} - className={cn( - "min-h-11 flex-row items-center gap-3 rounded-xl px-2 py-3 active:opacity-60", - store.submitting && "opacity-50", - )} - > - - {label} - - ); - })} - - - - ); -} diff --git a/apps/mobile/screens/(authenticated)/pages/[slug]/quick/index.ts b/apps/mobile/screens/(authenticated)/pages/[slug]/quick/index.ts deleted file mode 100644 index 9b9407d2235..00000000000 --- a/apps/mobile/screens/(authenticated)/pages/[slug]/quick/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { QuickFeedbackSheet } from "./QuickFeedbackSheet"; diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index fcacdabeb11..8e2fb31845f 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -10,6 +10,7 @@ "./modules/attachments-sheet/src/index.ts" ], "@superset/composer": ["./modules/composer/src/index.tsx"], + "@superset/symbol-button": ["./modules/symbol-button/src/index.tsx"], "@superset/live-activity": ["./modules/live-activity/src/index.ts"] }, "allowImportingTsExtensions": true diff --git a/bun.lock b/bun.lock index 5f8fe3a6d8b..4eb4f88d594 100644 --- a/bun.lock +++ b/bun.lock @@ -1437,6 +1437,7 @@ "node-pty@1.2.0-beta.14": "patches/node-pty@1.2.0-beta.14.patch", "@xterm/xterm@6.1.0-beta.302": "patches/@xterm%2Fxterm@6.1.0-beta.302.patch", "@pierre/trees@1.0.0-beta.5": "patches/@pierre%2Ftrees@1.0.0-beta.5.patch", + "expo-observe@57.0.19": "patches/expo-observe@57.0.19.patch", "metro@0.84.4": "patches/metro@0.84.4.patch", "react-native-screens@4.26.0": "patches/react-native-screens@4.26.0.patch", }, diff --git a/package.json b/package.json index 26cf6cdd176..169b0ce7ae6 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "@xterm/xterm@6.1.0-beta.302": "patches/@xterm%2Fxterm@6.1.0-beta.302.patch", "trpc-electron@0.1.2": "patches/trpc-electron@0.1.2.patch", "react-native-screens@4.26.0": "patches/react-native-screens@4.26.0.patch", - "@pierre/trees@1.0.0-beta.5": "patches/@pierre%2Ftrees@1.0.0-beta.5.patch" + "@pierre/trees@1.0.0-beta.5": "patches/@pierre%2Ftrees@1.0.0-beta.5.patch", + "expo-observe@57.0.19": "patches/expo-observe@57.0.19.patch" } } diff --git a/packages/i18n/locales/cs/messages.po b/packages/i18n/locales/cs/messages.po index b8da72a9657..4329526354b 100644 --- a/packages/i18n/locales/cs/messages.po +++ b/packages/i18n/locales/cs/messages.po @@ -3044,9 +3044,6 @@ msgstr "Zrušit úpravy" msgid "Cancel plan" msgstr "Zrušit tarif" -msgid "Cancel reply" -msgstr "Zrušit odpověď" - msgid "Canceled" msgstr "Zrušeno" @@ -5319,6 +5316,10 @@ msgstr "Smazat tým" msgid "Delete this" msgstr "Smazat toto" +msgctxt "quick reply button" +msgid "Delete this" +msgstr "Smazat toto" + msgid "Delete this comment?" msgstr "Smazat tento komentář?" @@ -9001,6 +9002,10 @@ msgstr "Hledám nainstalované prohlížeče…" msgid "Looks good" msgstr "Vypadá dobře" +msgctxt "quick reply button" +msgid "Looks good" +msgstr "Vypadá dobře" + msgid "Low" msgstr "Nízká" @@ -12192,6 +12197,10 @@ msgstr "Rychle vytvořit pracovní prostor" msgid "Quick feedback" msgstr "Rychlá zpětná vazba" +msgctxt "quick reply button" +msgid "Quick feedback" +msgstr "Rychlá zpětná vazba" + msgid "Quick Open File" msgstr "Rychle otevřít soubor" @@ -12707,9 +12716,6 @@ msgstr "Odpovědět" msgid "Reply to thread…" msgstr "Odpovědět do vlákna…" -msgid "Replying to {replyingTo}" -msgstr "Odpověď pro {replyingTo}" - msgid "repo" msgstr "repo" diff --git a/packages/i18n/locales/de/messages.po b/packages/i18n/locales/de/messages.po index aee57c8a8d3..a2664ec6fa0 100644 --- a/packages/i18n/locales/de/messages.po +++ b/packages/i18n/locales/de/messages.po @@ -3044,9 +3044,6 @@ msgstr "Bearbeitung abbrechen" msgid "Cancel plan" msgstr "Tarif kündigen" -msgid "Cancel reply" -msgstr "Antwort abbrechen" - msgid "Canceled" msgstr "Abgebrochen" @@ -5319,6 +5316,10 @@ msgstr "Team löschen" msgid "Delete this" msgstr "Das löschen" +msgctxt "quick reply button" +msgid "Delete this" +msgstr "Das löschen" + msgid "Delete this comment?" msgstr "Diesen Kommentar löschen?" @@ -9001,6 +9002,10 @@ msgstr "Installierte Browser werden gesucht…" msgid "Looks good" msgstr "Sieht gut aus" +msgctxt "quick reply button" +msgid "Looks good" +msgstr "Sieht gut aus" + msgid "Low" msgstr "Niedrig" @@ -12192,6 +12197,10 @@ msgstr "Arbeitsbereich schnell erstellen" msgid "Quick feedback" msgstr "Schnelles Feedback" +msgctxt "quick reply button" +msgid "Quick feedback" +msgstr "Schnelles Feedback" + msgid "Quick Open File" msgstr "Datei schnell öffnen" @@ -12707,9 +12716,6 @@ msgstr "Antworten" msgid "Reply to thread…" msgstr "Auf den Thread antworten…" -msgid "Replying to {replyingTo}" -msgstr "Antwort an {replyingTo}" - msgid "repo" msgstr "Repo" diff --git a/packages/i18n/locales/en/messages.po b/packages/i18n/locales/en/messages.po index 93ae0c28b9f..66f7003b25b 100644 --- a/packages/i18n/locales/en/messages.po +++ b/packages/i18n/locales/en/messages.po @@ -3044,9 +3044,6 @@ msgstr "Cancel edits" msgid "Cancel plan" msgstr "Cancel plan" -msgid "Cancel reply" -msgstr "Cancel reply" - msgid "Canceled" msgstr "Canceled" @@ -5319,6 +5316,10 @@ msgstr "Delete team" msgid "Delete this" msgstr "Delete this" +msgctxt "quick reply button" +msgid "Delete this" +msgstr "Delete this" + msgid "Delete this comment?" msgstr "Delete this comment?" @@ -9001,6 +9002,10 @@ msgstr "Looking for installed browsers…" msgid "Looks good" msgstr "Looks good" +msgctxt "quick reply button" +msgid "Looks good" +msgstr "Looks good" + msgid "Low" msgstr "Low" @@ -12192,6 +12197,10 @@ msgstr "Quick Create Workspace" msgid "Quick feedback" msgstr "Quick feedback" +msgctxt "quick reply button" +msgid "Quick feedback" +msgstr "Quick feedback" + msgid "Quick Open File" msgstr "Quick Open File" @@ -12707,9 +12716,6 @@ msgstr "Reply" msgid "Reply to thread…" msgstr "Reply to thread…" -msgid "Replying to {replyingTo}" -msgstr "Replying to {replyingTo}" - msgid "repo" msgstr "repo" diff --git a/packages/i18n/locales/es/messages.po b/packages/i18n/locales/es/messages.po index b4a707aa776..72049ec07b5 100644 --- a/packages/i18n/locales/es/messages.po +++ b/packages/i18n/locales/es/messages.po @@ -3044,9 +3044,6 @@ msgstr "Cancelar las ediciones" msgid "Cancel plan" msgstr "Cancelar el plan" -msgid "Cancel reply" -msgstr "Cancelar respuesta" - msgid "Canceled" msgstr "Cancelado" @@ -5319,6 +5316,10 @@ msgstr "Eliminar el equipo" msgid "Delete this" msgstr "Eliminar esto" +msgctxt "quick reply button" +msgid "Delete this" +msgstr "Eliminar esto" + msgid "Delete this comment?" msgstr "¿Eliminar este comentario?" @@ -9001,6 +9002,10 @@ msgstr "Buscando navegadores instalados…" msgid "Looks good" msgstr "Se ve bien" +msgctxt "quick reply button" +msgid "Looks good" +msgstr "Se ve bien" + msgid "Low" msgstr "Baja" @@ -12192,6 +12197,10 @@ msgstr "Creación rápida de espacio de trabajo" msgid "Quick feedback" msgstr "Comentario rápido" +msgctxt "quick reply button" +msgid "Quick feedback" +msgstr "Comentario rápido" + msgid "Quick Open File" msgstr "Apertura rápida de archivo" @@ -12707,9 +12716,6 @@ msgstr "Responder" msgid "Reply to thread…" msgstr "Responder al hilo…" -msgid "Replying to {replyingTo}" -msgstr "Respondiendo a {replyingTo}" - msgid "repo" msgstr "repo" diff --git a/packages/i18n/locales/fr/messages.po b/packages/i18n/locales/fr/messages.po index a5d60064f36..7c4adec7a43 100644 --- a/packages/i18n/locales/fr/messages.po +++ b/packages/i18n/locales/fr/messages.po @@ -3044,9 +3044,6 @@ msgstr "Annuler les modifications" msgid "Cancel plan" msgstr "Annuler le forfait" -msgid "Cancel reply" -msgstr "Annuler la réponse" - msgid "Canceled" msgstr "Annulées" @@ -5319,6 +5316,10 @@ msgstr "Supprimer l'équipe" msgid "Delete this" msgstr "Supprimer ceci" +msgctxt "quick reply button" +msgid "Delete this" +msgstr "Supprimer ceci" + msgid "Delete this comment?" msgstr "Supprimer ce commentaire ?" @@ -9001,6 +9002,10 @@ msgstr "Recherche des navigateurs installés…" msgid "Looks good" msgstr "Ça me va" +msgctxt "quick reply button" +msgid "Looks good" +msgstr "Ça me va" + msgid "Low" msgstr "Faible" @@ -12192,6 +12197,10 @@ msgstr "Créer un espace de travail" msgid "Quick feedback" msgstr "Retour rapide" +msgctxt "quick reply button" +msgid "Quick feedback" +msgstr "Retour rapide" + msgid "Quick Open File" msgstr "Ouverture rapide de fichier" @@ -12707,9 +12716,6 @@ msgstr "Répondre" msgid "Reply to thread…" msgstr "Répondre au fil…" -msgid "Replying to {replyingTo}" -msgstr "Réponse à {replyingTo}" - msgid "repo" msgstr "dépôt" diff --git a/packages/i18n/locales/id/messages.po b/packages/i18n/locales/id/messages.po index 4daaba57b5a..47ec7f419f4 100644 --- a/packages/i18n/locales/id/messages.po +++ b/packages/i18n/locales/id/messages.po @@ -3044,9 +3044,6 @@ msgstr "Batalkan suntingan" msgid "Cancel plan" msgstr "Batalkan paket" -msgid "Cancel reply" -msgstr "Batalkan balasan" - msgid "Canceled" msgstr "Dibatalkan" @@ -5319,6 +5316,10 @@ msgstr "Hapus tim" msgid "Delete this" msgstr "Hapus ini" +msgctxt "quick reply button" +msgid "Delete this" +msgstr "Hapus ini" + msgid "Delete this comment?" msgstr "Hapus komentar ini?" @@ -9001,6 +9002,10 @@ msgstr "Mencari browser yang terpasang…" msgid "Looks good" msgstr "Terlihat bagus" +msgctxt "quick reply button" +msgid "Looks good" +msgstr "Terlihat bagus" + msgid "Low" msgstr "Rendah" @@ -12192,6 +12197,10 @@ msgstr "Buat Workspace Cepat" msgid "Quick feedback" msgstr "Masukan cepat" +msgctxt "quick reply button" +msgid "Quick feedback" +msgstr "Masukan cepat" + msgid "Quick Open File" msgstr "Buka File Cepat" @@ -12707,9 +12716,6 @@ msgstr "Balas" msgid "Reply to thread…" msgstr "Balas thread…" -msgid "Replying to {replyingTo}" -msgstr "Membalas {replyingTo}" - msgid "repo" msgstr "repo" diff --git a/packages/i18n/locales/it/messages.po b/packages/i18n/locales/it/messages.po index ee53e90a790..3769e3164a6 100644 --- a/packages/i18n/locales/it/messages.po +++ b/packages/i18n/locales/it/messages.po @@ -3044,9 +3044,6 @@ msgstr "Annulla le modifiche" msgid "Cancel plan" msgstr "Disdici il piano" -msgid "Cancel reply" -msgstr "Annulla risposta" - msgid "Canceled" msgstr "Annullati" @@ -5319,6 +5316,10 @@ msgstr "Elimina il team" msgid "Delete this" msgstr "Elimina questo" +msgctxt "quick reply button" +msgid "Delete this" +msgstr "Elimina questo" + msgid "Delete this comment?" msgstr "Eliminare questo commento?" @@ -9001,6 +9002,10 @@ msgstr "Ricerca dei browser installati…" msgid "Looks good" msgstr "Mi sembra buono" +msgctxt "quick reply button" +msgid "Looks good" +msgstr "Mi sembra buono" + msgid "Low" msgstr "Bassa" @@ -12192,6 +12197,10 @@ msgstr "Crea workspace rapido" msgid "Quick feedback" msgstr "Feedback rapido" +msgctxt "quick reply button" +msgid "Quick feedback" +msgstr "Feedback rapido" + msgid "Quick Open File" msgstr "Apertura rapida file" @@ -12707,9 +12716,6 @@ msgstr "Rispondi" msgid "Reply to thread…" msgstr "Rispondi al thread…" -msgid "Replying to {replyingTo}" -msgstr "Risposta a {replyingTo}" - msgid "repo" msgstr "repo" diff --git a/packages/i18n/locales/ja/messages.po b/packages/i18n/locales/ja/messages.po index f4c9e0fadd7..748a5846290 100644 --- a/packages/i18n/locales/ja/messages.po +++ b/packages/i18n/locales/ja/messages.po @@ -3044,9 +3044,6 @@ msgstr "編集をキャンセル" msgid "Cancel plan" msgstr "プランをキャンセル" -msgid "Cancel reply" -msgstr "返信をキャンセル" - msgid "Canceled" msgstr "キャンセル済み" @@ -5319,6 +5316,10 @@ msgstr "チームを削除" msgid "Delete this" msgstr "これを削除" +msgctxt "quick reply button" +msgid "Delete this" +msgstr "これを削除" + msgid "Delete this comment?" msgstr "このコメントを削除しますか?" @@ -9001,6 +9002,10 @@ msgstr "インストール済みブラウザを検索中…" msgid "Looks good" msgstr "良さそう" +msgctxt "quick reply button" +msgid "Looks good" +msgstr "良さそう" + msgid "Low" msgstr "低" @@ -12192,6 +12197,10 @@ msgstr "ワークスペースをクイック作成" msgid "Quick feedback" msgstr "クイックフィードバック" +msgctxt "quick reply button" +msgid "Quick feedback" +msgstr "クイックフィードバック" + msgid "Quick Open File" msgstr "ファイルをクイックオープン" @@ -12707,9 +12716,6 @@ msgstr "返信" msgid "Reply to thread…" msgstr "スレッドに返信…" -msgid "Replying to {replyingTo}" -msgstr "{replyingTo} に返信中" - msgid "repo" msgstr "リポジトリ" diff --git a/packages/i18n/locales/ko/messages.po b/packages/i18n/locales/ko/messages.po index 0daac8d1bf9..3eb46438dcd 100644 --- a/packages/i18n/locales/ko/messages.po +++ b/packages/i18n/locales/ko/messages.po @@ -3044,9 +3044,6 @@ msgstr "편집 취소" msgid "Cancel plan" msgstr "플랜 해지" -msgid "Cancel reply" -msgstr "답글 취소" - msgid "Canceled" msgstr "취소됨" @@ -5319,6 +5316,10 @@ msgstr "팀 삭제" msgid "Delete this" msgstr "이것을 삭제" +msgctxt "quick reply button" +msgid "Delete this" +msgstr "이것을 삭제" + msgid "Delete this comment?" msgstr "이 댓글을 삭제할까요?" @@ -9001,6 +9002,10 @@ msgstr "설치된 브라우저를 찾는 중…" msgid "Looks good" msgstr "좋아 보입니다" +msgctxt "quick reply button" +msgid "Looks good" +msgstr "좋아 보입니다" + msgid "Low" msgstr "낮음" @@ -12192,6 +12197,10 @@ msgstr "워크스페이스 빠른 생성" msgid "Quick feedback" msgstr "빠른 피드백" +msgctxt "quick reply button" +msgid "Quick feedback" +msgstr "빠른 피드백" + msgid "Quick Open File" msgstr "파일 빠른 열기" @@ -12707,9 +12716,6 @@ msgstr "답글" msgid "Reply to thread…" msgstr "스레드에 답글…" -msgid "Replying to {replyingTo}" -msgstr "{replyingTo}님에게 답글 작성 중" - msgid "repo" msgstr "리포" diff --git a/packages/i18n/locales/nl/messages.po b/packages/i18n/locales/nl/messages.po index e73d990d7bd..bda9ef1788f 100644 --- a/packages/i18n/locales/nl/messages.po +++ b/packages/i18n/locales/nl/messages.po @@ -3044,9 +3044,6 @@ msgstr "Bewerkingen annuleren" msgid "Cancel plan" msgstr "Plan opzeggen" -msgid "Cancel reply" -msgstr "Reactie annuleren" - msgid "Canceled" msgstr "Geannuleerd" @@ -5319,6 +5316,10 @@ msgstr "Team verwijderen" msgid "Delete this" msgstr "Dit verwijderen" +msgctxt "quick reply button" +msgid "Delete this" +msgstr "Dit verwijderen" + msgid "Delete this comment?" msgstr "Deze reactie verwijderen?" @@ -9001,6 +9002,10 @@ msgstr "Zoeken naar geïnstalleerde browsers…" msgid "Looks good" msgstr "Ziet er goed uit" +msgctxt "quick reply button" +msgid "Looks good" +msgstr "Ziet er goed uit" + msgid "Low" msgstr "Laag" @@ -12192,6 +12197,10 @@ msgstr "Werkruimte snel aanmaken" msgid "Quick feedback" msgstr "Snelle feedback" +msgctxt "quick reply button" +msgid "Quick feedback" +msgstr "Snelle feedback" + msgid "Quick Open File" msgstr "Bestand snel openen" @@ -12707,9 +12716,6 @@ msgstr "Antwoorden" msgid "Reply to thread…" msgstr "Reageren op thread…" -msgid "Replying to {replyingTo}" -msgstr "Antwoord aan {replyingTo}" - msgid "repo" msgstr "repo" diff --git a/packages/i18n/locales/pl/messages.po b/packages/i18n/locales/pl/messages.po index cdd528261aa..d9584becb5a 100644 --- a/packages/i18n/locales/pl/messages.po +++ b/packages/i18n/locales/pl/messages.po @@ -3044,9 +3044,6 @@ msgstr "Anuluj edycję" msgid "Cancel plan" msgstr "Anuluj plan" -msgid "Cancel reply" -msgstr "Anuluj odpowiedź" - msgid "Canceled" msgstr "Anulowane" @@ -5319,6 +5316,10 @@ msgstr "Usuń zespół" msgid "Delete this" msgstr "Usuń to" +msgctxt "quick reply button" +msgid "Delete this" +msgstr "Usuń to" + msgid "Delete this comment?" msgstr "Usunąć ten komentarz?" @@ -9001,6 +9002,10 @@ msgstr "Szukanie zainstalowanych przeglądarek…" msgid "Looks good" msgstr "Wygląda dobrze" +msgctxt "quick reply button" +msgid "Looks good" +msgstr "Wygląda dobrze" + msgid "Low" msgstr "Niski" @@ -12192,6 +12197,10 @@ msgstr "Szybkie tworzenie obszaru roboczego" msgid "Quick feedback" msgstr "Szybka opinia" +msgctxt "quick reply button" +msgid "Quick feedback" +msgstr "Szybka opinia" + msgid "Quick Open File" msgstr "Szybkie otwieranie pliku" @@ -12707,9 +12716,6 @@ msgstr "Odpowiedz" msgid "Reply to thread…" msgstr "Odpowiedz w wątku…" -msgid "Replying to {replyingTo}" -msgstr "Odpowiadasz: {replyingTo}" - msgid "repo" msgstr "repo" diff --git a/packages/i18n/locales/pt-BR/messages.po b/packages/i18n/locales/pt-BR/messages.po index 71bea48d49a..4cfa218da20 100644 --- a/packages/i18n/locales/pt-BR/messages.po +++ b/packages/i18n/locales/pt-BR/messages.po @@ -3044,9 +3044,6 @@ msgstr "Cancelar as edições" msgid "Cancel plan" msgstr "Cancelar plano" -msgid "Cancel reply" -msgstr "Cancelar resposta" - msgid "Canceled" msgstr "Canceladas" @@ -5319,6 +5316,10 @@ msgstr "Excluir equipe" msgid "Delete this" msgstr "Excluir isto" +msgctxt "quick reply button" +msgid "Delete this" +msgstr "Excluir isto" + msgid "Delete this comment?" msgstr "Excluir este comentário?" @@ -9001,6 +9002,10 @@ msgstr "Procurando navegadores instalados…" msgid "Looks good" msgstr "Parece bom" +msgctxt "quick reply button" +msgid "Looks good" +msgstr "Parece bom" + msgid "Low" msgstr "Baixa" @@ -12192,6 +12197,10 @@ msgstr "Criação rápida de área de trabalho" msgid "Quick feedback" msgstr "Feedback rápido" +msgctxt "quick reply button" +msgid "Quick feedback" +msgstr "Feedback rápido" + msgid "Quick Open File" msgstr "Abertura rápida de arquivo" @@ -12707,9 +12716,6 @@ msgstr "Responder" msgid "Reply to thread…" msgstr "Responder à thread…" -msgid "Replying to {replyingTo}" -msgstr "Respondendo a {replyingTo}" - msgid "repo" msgstr "repo" diff --git a/packages/i18n/locales/ru/messages.po b/packages/i18n/locales/ru/messages.po index 68baf9f4005..e4f3daa96f6 100644 --- a/packages/i18n/locales/ru/messages.po +++ b/packages/i18n/locales/ru/messages.po @@ -3044,9 +3044,6 @@ msgstr "Отменить правки" msgid "Cancel plan" msgstr "Отменить тариф" -msgid "Cancel reply" -msgstr "Отменить ответ" - msgid "Canceled" msgstr "Отменена" @@ -5319,6 +5316,10 @@ msgstr "Удалить команду" msgid "Delete this" msgstr "Удалить это" +msgctxt "quick reply button" +msgid "Delete this" +msgstr "Удалить это" + msgid "Delete this comment?" msgstr "Удалить этот комментарий?" @@ -9001,6 +9002,10 @@ msgstr "Поиск установленных браузеров…" msgid "Looks good" msgstr "Выглядит хорошо" +msgctxt "quick reply button" +msgid "Looks good" +msgstr "Выглядит хорошо" + msgid "Low" msgstr "Низкий" @@ -12192,6 +12197,10 @@ msgstr "Быстро создать рабочую область" msgid "Quick feedback" msgstr "Быстрый отзыв" +msgctxt "quick reply button" +msgid "Quick feedback" +msgstr "Быстрый отзыв" + msgid "Quick Open File" msgstr "Быстро открыть файл" @@ -12707,9 +12716,6 @@ msgstr "Ответить" msgid "Reply to thread…" msgstr "Ответить в ветке…" -msgid "Replying to {replyingTo}" -msgstr "Ответ для {replyingTo}" - msgid "repo" msgstr "репозиторий" diff --git a/packages/i18n/locales/tr/messages.po b/packages/i18n/locales/tr/messages.po index 86178864086..1d7fbb34cdb 100644 --- a/packages/i18n/locales/tr/messages.po +++ b/packages/i18n/locales/tr/messages.po @@ -3044,9 +3044,6 @@ msgstr "Düzenlemeleri iptal et" msgid "Cancel plan" msgstr "Planı iptal et" -msgid "Cancel reply" -msgstr "Yanıtı iptal et" - msgid "Canceled" msgstr "İptal edildi" @@ -5319,6 +5316,10 @@ msgstr "Takımı sil" msgid "Delete this" msgstr "Bunu sil" +msgctxt "quick reply button" +msgid "Delete this" +msgstr "Bunu sil" + msgid "Delete this comment?" msgstr "Bu yorum silinsin mi?" @@ -9001,6 +9002,10 @@ msgstr "Kurulu tarayıcılar aranıyor…" msgid "Looks good" msgstr "İyi görünüyor" +msgctxt "quick reply button" +msgid "Looks good" +msgstr "İyi görünüyor" + msgid "Low" msgstr "Düşük" @@ -12192,6 +12197,10 @@ msgstr "Hızlı çalışma alanı oluştur" msgid "Quick feedback" msgstr "Hızlı geri bildirim" +msgctxt "quick reply button" +msgid "Quick feedback" +msgstr "Hızlı geri bildirim" + msgid "Quick Open File" msgstr "Hızlı dosya aç" @@ -12707,9 +12716,6 @@ msgstr "Yanıtla" msgid "Reply to thread…" msgstr "Konuya yanıt verin…" -msgid "Replying to {replyingTo}" -msgstr "{replyingTo} kullanıcısına yanıt veriliyor" - msgid "repo" msgstr "depo" diff --git a/packages/i18n/locales/vi/messages.po b/packages/i18n/locales/vi/messages.po index 778868bf113..ae3893ba3e1 100644 --- a/packages/i18n/locales/vi/messages.po +++ b/packages/i18n/locales/vi/messages.po @@ -3044,9 +3044,6 @@ msgstr "Hủy chỉnh sửa" msgid "Cancel plan" msgstr "Hủy gói" -msgid "Cancel reply" -msgstr "Hủy trả lời" - msgid "Canceled" msgstr "Đã hủy" @@ -5319,6 +5316,10 @@ msgstr "Xóa nhóm" msgid "Delete this" msgstr "Xóa phần này" +msgctxt "quick reply button" +msgid "Delete this" +msgstr "Xóa phần này" + msgid "Delete this comment?" msgstr "Xóa bình luận này?" @@ -9001,6 +9002,10 @@ msgstr "Đang tìm các trình duyệt đã cài…" msgid "Looks good" msgstr "Trông ổn" +msgctxt "quick reply button" +msgid "Looks good" +msgstr "Trông ổn" + msgid "Low" msgstr "Thấp" @@ -12192,6 +12197,10 @@ msgstr "Tạo nhanh workspace" msgid "Quick feedback" msgstr "Phản hồi nhanh" +msgctxt "quick reply button" +msgid "Quick feedback" +msgstr "Phản hồi nhanh" + msgid "Quick Open File" msgstr "Mở nhanh tệp" @@ -12707,9 +12716,6 @@ msgstr "Trả lời" msgid "Reply to thread…" msgstr "Trả lời luồng…" -msgid "Replying to {replyingTo}" -msgstr "Đang trả lời {replyingTo}" - msgid "repo" msgstr "repo" diff --git a/packages/i18n/locales/zh-CN/messages.po b/packages/i18n/locales/zh-CN/messages.po index 33f35056043..e300534172a 100644 --- a/packages/i18n/locales/zh-CN/messages.po +++ b/packages/i18n/locales/zh-CN/messages.po @@ -3044,9 +3044,6 @@ msgstr "取消编辑" msgid "Cancel plan" msgstr "取消方案" -msgid "Cancel reply" -msgstr "取消回复" - msgid "Canceled" msgstr "已取消" @@ -5319,6 +5316,10 @@ msgstr "删除团队" msgid "Delete this" msgstr "删除这里" +msgctxt "quick reply button" +msgid "Delete this" +msgstr "删除这里" + msgid "Delete this comment?" msgstr "删除此评论?" @@ -9001,6 +9002,10 @@ msgstr "正在查找已安装的浏览器…" msgid "Looks good" msgstr "看起来不错" +msgctxt "quick reply button" +msgid "Looks good" +msgstr "看起来不错" + msgid "Low" msgstr "低" @@ -12192,6 +12197,10 @@ msgstr "快速创建工作区" msgid "Quick feedback" msgstr "快速反馈" +msgctxt "quick reply button" +msgid "Quick feedback" +msgstr "快速反馈" + msgid "Quick Open File" msgstr "快速打开文件" @@ -12707,9 +12716,6 @@ msgstr "回复" msgid "Reply to thread…" msgstr "回复讨论串…" -msgid "Replying to {replyingTo}" -msgstr "正在回复 {replyingTo}" - msgid "repo" msgstr "仓库" diff --git a/packages/i18n/locales/zh-TW/messages.po b/packages/i18n/locales/zh-TW/messages.po index 2dbd933c128..5c3bea16cf5 100644 --- a/packages/i18n/locales/zh-TW/messages.po +++ b/packages/i18n/locales/zh-TW/messages.po @@ -3044,9 +3044,6 @@ msgstr "取消編輯" msgid "Cancel plan" msgstr "取消方案" -msgid "Cancel reply" -msgstr "取消回覆" - msgid "Canceled" msgstr "已取消" @@ -5319,6 +5316,10 @@ msgstr "刪除團隊" msgid "Delete this" msgstr "刪除這裡" +msgctxt "quick reply button" +msgid "Delete this" +msgstr "刪除這裡" + msgid "Delete this comment?" msgstr "刪除此留言?" @@ -9001,6 +9002,10 @@ msgstr "正在查詢已安裝的瀏覽器…" msgid "Looks good" msgstr "看起來不錯" +msgctxt "quick reply button" +msgid "Looks good" +msgstr "看起來不錯" + msgid "Low" msgstr "低" @@ -12192,6 +12197,10 @@ msgstr "快速建立工作區" msgid "Quick feedback" msgstr "快速回饋" +msgctxt "quick reply button" +msgid "Quick feedback" +msgstr "快速回饋" + msgid "Quick Open File" msgstr "快速開啟檔案" @@ -12707,9 +12716,6 @@ msgstr "回覆" msgid "Reply to thread…" msgstr "回覆討論串…" -msgid "Replying to {replyingTo}" -msgstr "正在回覆 {replyingTo}" - msgid "repo" msgstr "儲存庫" diff --git a/packages/i18n/src/format/format.test.ts b/packages/i18n/src/format/format.test.ts index 6f0bfaf5d9a..595ea625d9b 100644 --- a/packages/i18n/src/format/format.test.ts +++ b/packages/i18n/src/format/format.test.ts @@ -100,6 +100,18 @@ describe("formatRelativeTime", () => { ).toBe("3d ago"); }); + test("a timestamp that leads the clock still reads as the present", () => { + const justPosted = new Date(NOW.getTime() + 400); + expect(formatCompactRelativeTime(justPosted, NOW)).toBe("now"); + expect(formatCompactRelativeTime(NOW, NOW)).toBe("now"); + }); + + test("a real future time keeps the future tense", () => { + expect( + formatCompactRelativeTime(new Date(NOW.getTime() + 2 * 3600 * 1000), NOW), + ).toBe("in 2h"); + }); + test("age fits two digits and a unit", () => { const now = new Date("2026-08-28T12:00:00Z").getTime(); const minute = 60 * 1000; diff --git a/packages/shared/src/page-comments/popoverLayout.test.ts b/packages/shared/src/page-comments/popoverLayout.test.ts index 8c7f3098f94..5ba4e158ac0 100644 --- a/packages/shared/src/page-comments/popoverLayout.test.ts +++ b/packages/shared/src/page-comments/popoverLayout.test.ts @@ -94,14 +94,29 @@ describe("popoverPlacement pinSize", () => { const blockTop = 300; const blockHeight = 120; const cardHeight = 180; + const visible = 800 - 340; const { top } = popoverPlacement({ point: { x: 40, y: blockTop + blockHeight }, - container: { width: 390, height: 800 - 340 }, + container: { width: 390, height: visible }, height: cardHeight, pinSize: blockHeight, }); expect(top + cardHeight).toBeLessThanOrEqual(blockTop); expect(top).toBeGreaterThanOrEqual(EDGE); + expect(top + cardHeight).toBeLessThanOrEqual(visible); + }); + + test("a block under the keyboard pins its card to the visible bottom", () => { + const cardHeight = 180; + const visible = 611; + const { top } = popoverPlacement({ + point: { x: 40, y: 717 + 26 }, + container: { width: 440, height: visible }, + height: cardHeight, + pinSize: 26, + maxWidth: 440, + }); + expect(top).toBe(visible - cardHeight - EDGE); }); test("a taller pin pushes a flipped card further up", () => { diff --git a/packages/shared/src/page-comments/popoverLayout.ts b/packages/shared/src/page-comments/popoverLayout.ts index 9360d93b8b9..38610451c95 100644 --- a/packages/shared/src/page-comments/popoverLayout.ts +++ b/packages/shared/src/page-comments/popoverLayout.ts @@ -39,10 +39,14 @@ export function popoverPlacement({ // The pin's box hangs up and to the right of its point: it spans // [point.y - pinSize, point.y] vertically and starts at point.x. const belowTop = point.y + GAP; - const top = + const desiredTop = belowTop + height + EDGE <= container.height ? belowTop - : Math.max(EDGE, point.y - pinSize - GAP - height); + : point.y - pinSize - GAP - height; + const top = Math.max( + EDGE, + Math.min(desiredTop, container.height - height - EDGE), + ); const left = Math.min( Math.max(EDGE, point.x), Math.max(EDGE, container.width - width - EDGE), diff --git a/patches/README.md b/patches/README.md index abb46ee748b..37e666db43d 100644 --- a/patches/README.md +++ b/patches/README.md @@ -402,3 +402,15 @@ bun test apps/desktop/src/pierre-trees-lookup-patch.test.ts **Removing:** delete the patch, the `patchedDependencies` entry and the guard test once @pierre/trees ships a release whose segment walk stops at a file node. + +## expo-observe (`expo-observe@.patch`) + +**Why:** its podspec asks for `swift_version = '6.0'`, and the package keeps +mutable global state (`Observability.swift`) that Swift 6 treats as an error +rather than a warning. Under Xcode 26.3 that fails every local iOS build in a +package nobody imports directly — `expo-observe` arrives as a transitive Expo +SDK dependency. The patch drops that one line to `'5.0'`, which downgrades the +diagnostic and changes no codegen: the package uses no Swift-6-only syntax. + +**Guard:** `apps/mobile/expo-observe-swift-version-patch.test.ts`. Check +whether upstream relaxed the podspec before re-applying after an SDK bump. diff --git a/patches/expo-observe@57.0.19.patch b/patches/expo-observe@57.0.19.patch new file mode 100644 index 00000000000..e14978b7c20 --- /dev/null +++ b/patches/expo-observe@57.0.19.patch @@ -0,0 +1,13 @@ +diff --git a/ios/ExpoObserve.podspec b/ios/ExpoObserve.podspec +index a4809aa11ace762da2f0f6bcdd2460cecc94d79c..af586b4ad9aa2fc84e5f34f71431803deb115a16 100644 +--- a/ios/ExpoObserve.podspec ++++ b/ios/ExpoObserve.podspec +@@ -14,7 +14,7 @@ Pod::Spec.new do |s| + :ios => '16.4', + :tvos => '16.4' + } +- s.swift_version = '6.0' ++ s.swift_version = '5.0' + s.source = { git: 'https://github.com/expo/expo.git' } + s.static_framework = true + diff --git a/plans/page-comment-bottom-sheet.html b/plans/page-comment-bottom-sheet.html new file mode 100644 index 00000000000..8e4ced3e417 --- /dev/null +++ b/plans/page-comment-bottom-sheet.html @@ -0,0 +1,1207 @@ + + + + + + One sheet for page comments + + + +
+
+

apps/mobile · page comments

+

One sheet, not a floating card

+

+ Replacing the anchored comment popover with a single Liquid Glass sheet + that carries the quick replies and the composer — built the way + Instagram, Snapchat and Messages build the same thing. +

+
+ +
+

What's on screen today

+

+ Tap a block in comment mode and a card is placed beside it by + popoverPlacement(), which flips it above or below the + block, clamps it under the header, and subtracts the keyboard from the + container so it has somewhere to land. Quick feedback is a second + surface, pushed from inside that card. +

+ +
+
+
+
+
+ 10:58 + + + + +
+
+
‹
+
+ ◎ + 💬 + ↥ +
+
+
+
+
+
+
+
+
+
+
🗑⚡👍
+
✕
+
+
+
H
+
Write a comment↑
+
+
+
+
+
+
+ qwertyuiop +
+
+ asdfghjkl +
+
+ ⇧zxcvbnm⌫ +
+
+ 123spacereturn +
+
+
+
+
+ Today. The card is a free-floating object that has to be + placed. Here it flipped above the block — and covers the page it is + a comment about. +
+
+ +
+

Why the placement keeps fighting us

+
    +
  • + 1 + Two axes of guessing. The card starts at an assumed + ESTIMATED_HEIGHT = 180, measures itself in + onLayout, then moves — the first frame is in the + wrong place. +
  • +
  • + 2 + The keyboard is modelled by hand: + keyboardWillShow listeners shrink the container and a + Reanimated withTiming(250ms) approximates a curve + UIKit owns. +
  • +
  • + 3 + On a tall block there is nowhere good to go. The flip puts + the card over the selection; the clamp pins it under the glass + header. Both branches hide what you are commenting on. +
  • +
  • + 4 + Quick feedback is a different surface — ⚡ pushes + pages/[slug]/quick, a real formSheet, on top of a + card that is imitating one. +
  • +
  • + 5 + Dismissal is hand-built: an ✕ in the card plus a + pointer-down message from inside the WebView to catch + outside taps. +
  • +
  • + 6 + It is a rectangle we drew. Nothing about it is a system + surface, so it gets no glass, no grabber, no drag, and no + keyboard choreography. +
  • +
+
+
+
+ +
+

How everyone else does quick replies

+

+ Instagram, Snapchat, WhatsApp and Messages all solved this, and they + converged on nearly the same object. Worth copying the parts that are + settled rather than re-deciding them. +

+ +
+
+
+
+
+ 10:58 + + + + +
+
+
+
+
+
+ ❤️🙌🔥👏😢😍 + ⊕ +
+
Send message
+
+
+
+
+ Instagram story reply. Six emoji in a row directly above the + message field. Tap one and it sends immediately to the author's DMs. + The ⊕ at the end opens the full emoji keyboard. +
+
+ +
+
+
+
+ 10:58 + + + + +
+
+
on it
+
+ ❤️👍👎😂‼️❓ +
+
shipped the sheet — take a look?
+
nice
+
+
+
+
+ Messages tapbacks. Long-press a bubble and six reactions + arrive in a floating glass capsule pinned to that bubble. + The reaction attaches to the message, not to the conversation. +
+
+ +
+

The five rules they agree on

+
    +
  • + 1 + The row lives immediately above the input, in the same + container. It never appears as its own screen, and it rides + the keyboard up rather than being replaced by it. +
  • +
  • + 2 + One tap sends. No confirm, no second step, no "are + you sure" — haptic, send, dismiss. The whole point is that it is + faster than typing. +
  • +
  • + 3 + Six or fewer, fixed. Instagram ships six emoji, + Messages six tapbacks, Snapchat seven Bitmoji reactions. Past + that it stops being scannable and becomes a menu. +
  • +
  • + 4 + "More" goes at the end. Instagram's ⊕ and WhatsApp's + + are the last item in the row and open the full picker. + The escape hatch never sits in the middle of the fast path. +
  • +
  • + 5 + The target is quoted. Instagram shows the story + behind the bar, Snapchat attaches the reaction under the message, + WhatsApp quotes the bubble. You always see what you are replying + to while you reply. +
  • +
+
+
+ +
+

+ Two places our case differs, and what to do about it. +

+

+ Our quick replies are actions, not emotions. ❤️ needs no label + in any language; "ask for this to be removed" does. Glyph-only is why + those rows read as reaction bars rather than forms — but the current + trash / ⚡ / thumbs-up row is already glyph-only and already ambiguous + at a glance. Adopt the shape, not the bareness: SF Symbol with a + one-word caption under it. +

+

+ ⚡ is in the wrong position. It is the "more" affordance and it + currently sits between the two fast actions. By rule 4 it belongs + last: Remove · Looks good · More. +

+
+
+ +
+

The proposal

+

+ Selecting a block opens one sheet, at a detent that fits its contents. + On iOS 26 a partial-detent sheet is Liquid Glass by default and detaches + from the screen edges, so the page stays visible behind and around it — + which is exactly the property the floating card was trying to buy and + kept losing. +

+ +
+
+
+
+
+ 10:58 + + + + +
+
+
‹
+
+ ◎ + 💬 + ↥ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + The relay reconnects on a backoff capped at 30s… +
+
+
🗑Remove
+
👍Looks good
+
⋯More
+
+
+
H
+
Write a comment↑
+
+
+
+
+
+ Resting state. fitToContents, keyboard down, + glass over the page. Two quick replies are one tap; "More" is last, + per rule 4. +
+
+ +
+
+
+
+ 10:58 + + + + +
+
+
‹
+
+ ◎ + 💬 + ↥ +
+
+
+
+
+
+
+
+
+
+
+
+
+ + The relay reconnects on a backoff capped at 30s… +
+
+
🗑Remove
+
👍Looks good
+
⋯More
+
+
+
H
+
this number is stale↑
+
+
+
+
+ qwertyuiop +
+
+ asdfghjkl +
+
+ ⇧zxcvbnm⌫ +
+
+ 123spacereturn +
+
+
+
+
+ Typing. UIKit lifts the sheet with the keyboard and squares + off its bottom corners. The quick row stays — rule 1. We scroll the + WebView so the highlight stays in the strip that's left. +
+
+ +
+

What the system gives us for free

+
    +
  • ✓Glass. A partial-detent sheet on iOS 26 gets the material, the specular edge and the shadow with no code — the same language as the toolbar capsules already floating over this page.
  • +
  • ✓Placement. Always bottom, always full width, always a height that fits its contents.
  • +
  • ✓Keyboard. UIKit raises and lowers it on the real curve. Delete the listeners and the timing constant.
  • +
  • ✓Dismissal. Drag the grabber, tap outside, swipe — all native, all one router.back() that clears the selection.
  • +
  • ✓Room to grow. A thread list, an intent picker, an attachment row all have somewhere to live. The card had nowhere.
  • +
+
+
+ +
+
+ 01 + Enter comment mode +

Viewfinder button in the glass toolbar, unchanged.

+
+
+ 02 + Tap a block +

Haptic, blue highlight, anchor stored via setPick — unchanged.

+
+
+ 03 + Sheet rises +

Keyboard down. Page scrolls so the highlight clears the sheet.

+
+
+ 04 + Quick reply, or type +

One tap posts and dismisses. Tapping the field raises the keyboard and the sheet with it.

+
+
+ 05 + Send +

Thread created, sheet closes, selection clears, pin lands on the block.

+
+
+
+ +
+

How to build the container

+

+ Three shapes are available, and the repo already has a precedent for + one: workspace/[id]/line-comment is this exact thing — a + comment composer in a formSheet route, fed its anchor through a store. +

+ +
+ + + + + + + + + + + + + + + + + + + + + +
ShapeWhat it costs
Take +

expo-router formSheet route

+

pages/[slug]/comment, sheetAllowedDetents: "fitToContents", sheetGrabberVisible, anchor read from pageCommentStore.

+
+

Matches the mobile convention and the line-comment precedent, and it is the only option that gets Liquid Glass from the system rather than imitating it.

+

Costs a route transition and moves submit to another screen — but usePageComments is keyed by page and version, so the store is already the shared surface.

+
Skip

In-screen sheet

An absolutely positioned view with useAnimatedKeyboard, like ReplyBar.

Keeps state local and skips the transition, but re-implements detents, grabber, drag-to-dismiss and the dim layer — and would need GlassView to fake a material the sheet gets natively. This is the popover's problem in a new position.

Skip

Composer overlay, no sheet

The native module pinned to the bottom with backdrop: "dim", using its own expand behaviour.

Fewest moving parts on paper, but its expanded mode claims the whole screen. It is built for "write a prompt", not "annotate the thing you can see".

+
+ +
+

The one thing the sheet doesn't solve: the block can end up underneath it.

+

+ No protocol change needed. The host already knows the selection's rect + (rects[PENDING_ANCHOR_ID], kept live by the + track message) and the current offset + (scrollYRef), so it can send the existing + restore-scroll with + y = scrollY + rect.top − targetTop when the sheet opens, + and again when the keyboard changes the visible strip. +

+
+ +
+

Known trap: the Liquid Glass flare on formSheet.

+

+ react-native-screens never sets + UISheetPresentationController.backgroundEffect (iOS 26.1), + so a partial-detent sheet keeps the material's interactive + glass: a bright specular highlight chases your finger across the + sheet, and on a dark background it reads as a glowing blob. Filed + September 2026 against 4.26.2 / 4.27.0 — we are on 4.26.0 — and still + open. The reporter's patch sets a UIColorEffect matching + the sheet background. Budget for either a patch or a plainer sheet + background until it lands upstream. +

+
+
+ +
+

"Can we use native text input?"

+

+ Worth splitting: TextInput is already a real + UITextView. The live choice is whether comments adopt + modules/composer, the SwiftUI composer that Home, the + terminal and files-changed already use. +

+ +
+ + + + + + + + + + + + + + + + +
OptionWhat you getWhat it costs

Keep CommentComposer

RN TextInput + avatar + send button.

Already themed with uniwind, already used by ReplyBar and the all-comments sheet, so comment input looks the same everywhere. Pure JS: iterate in seconds.

No dictation, no paste handling, no native height reporting. The quick row stays RN buttons we draw ourselves.

Adopt the Composer module

quickKeys + backdrop: "passthrough".

The quick replies become the native quickKeys strip — SF Symbols, onQuickKeyPress by id, dividers between groups. That is rules 1–4 already implemented in Swift. Plus dictation, paste, isSending, and onHeightChange to size the sheet.

Built for agent sessions: models, session tabs, slash commands and attachments all passed empty. Its expand/backdrop machinery overlaps the sheet, so passthrough is mandatory. quickKeys are glyph-only, which loses the caption rule 5 wants. And iterating means a native build (-scheme Composer).

+
+ +

+ Recommendation: ship the sheet with the RN composer, then swap. + The sheet is the change that removes the bugs; the input is a swap + behind two props (onSubmit, pending). Doing + both at once puts a pod rebuild between us and knowing whether the + layout is right. If the quick-keys strip turns out to be the + look you want, it lands as a second commit with nothing else moving. +

+
+ +
+

What changes in the tree

+
+ + + + + + + + + + + + + +
 PathNote
+app/(authenticated)/pages/[slug]/comment.tsx

Route re-export, plus the Stack.Screen entry with fitToContents and glassHeaderOptions.

+screens/…/pages/[slug]/comment/CommentSheet.tsx

Quoted anchor, quick row, composer. Modelled on LineCommentSheet.

−screens/…/components/CommentPopover/

Gone: the card, the keyboard listeners, the ✕, the withTiming settle.

~screens/…/components/ComposerActions/

Reorders to Remove · Looks good · More and gains captions. Still shared with whatever renders it.

−packages/shared/src/page-comments/popoverLayout.ts

Mobile stops calling it. packages/ui still uses it for desktop so the function stays — but insetTop and the flip clamp exist only for mobile and can come back out.

~app/(authenticated)/pages/[slug]/quick.tsx

Becomes the "More" expansion inside the sheet rather than a sheet of its own.

~PageDetailScreen.tsx

Keeps selection, highlight, pins and createAnchored; loses the popover branch; gains the scroll-into-view call.

+
+ +
+

+ Note on the working tree. The in-flight insetTop + work — popoverLayout.ts, its tests, and the + headerHeight plumbing — exists to keep the floating card + clear of the glass header. The sheet makes that unnecessary on + mobile. The PageFrame contentInset fix is + unrelated and should stay either way. +

+
+
+ +
+

Open questions

+

Three calls worth making before anyone writes code.

+ +
+

1. Does the keyboard come up on open?

+

+ The popover autofocuses, and Instagram raises the keyboard with its + reaction row — but Instagram's sheet is the whole screen, so + it costs nothing. Ours has to leave the page visible, and autofocus + spends half the screen before you have decided whether to type. +

+

Recommend: open with the keyboard down. Quick replies are one tap; the field is one more.

+
+ +
+

2. Captions under the quick replies, or glyphs alone?

+

+ Glyph-only is what makes these rows feel like reaction bars, and it is + what the native quickKeys strip gives us. But 🗑 on a + document means "delete this comment" at least as readily as "ask for + this to be removed". +

+

Recommend: SF Symbol with a one-word caption. Revisit once the labels have earned their recognition.

+
+ +
+

3. Does the sheet show the thread when one already exists?

+

+ Tapping an existing pin opens the full all-comments sheet with focus. + With a per-anchor sheet in place, a pin could open the same sheet + showing that thread with a reply field — which is how every app in the + prior art handles it. +

+

Recommend: out of scope here. Get new comments onto one surface first, unify pins after.

+
+
+ +
+

Sources

+ +
+
+ +