208 lines · 7.5 KB · TypeScript
RawDownload
import { GlassContainer, GlassView } from "expo-glass-effect";import { useEffect, useState } from "react";import { Text as SystemText, View } from "react-native";import Animated, {  Easing,  FadeIn,  FadeOut,  ReduceMotion,  useAnimatedStyle,  useSharedValue,  withTiming,} from "react-native-reanimated";import { withUniwind } from "uniwind"; import { AppText as Text } from "../../components/AppText";import { ControlPill } from "../../components/ControlPill";import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; const CONTROL_HEIGHT = 44;const CONTROL_COMPOSER_GAP = 8;const GLASS_MERGE_SPACING = 12;const CONTROL_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System);const CONTROL_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System);const CONTROL_TIMING = {  duration: 240,  easing: Easing.out(Easing.cubic),  reduceMotion: ReduceMotion.System,} as const;const CONTROL_SEPARATION = (16 + CONTROL_HEIGHT) / 2; // Expo reapplies glass after native layout and window reattachment, when UIKit// can otherwise leave the label visible but lose the material behind it.const UniwindGlassView = withUniwind(GlassView, {  style: { fromClassName: "className" },});const UniwindGlassContainer = withUniwind(GlassContainer, {  style: { fromClassName: "className" },});const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView); export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOSER_GAP; export function FloatingWorkingControl(props: {  readonly colorScheme: "light" | "dark";  readonly startedAt: string | null;  readonly showScrollToEnd: boolean;  readonly onScrollToEnd: () => void;}) {  const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0);   useEffect(() => {    separationProgress.value = withTiming(props.showScrollToEnd ? 1 : 0, CONTROL_TIMING);  }, [props.showScrollToEnd, separationProgress]);   const timerStyle = useAnimatedStyle(() => ({    transform: [{ translateX: CONTROL_SEPARATION * (1 - separationProgress.value) }],  }));  const arrowTransformStyle = useAnimatedStyle(() => ({    transform: [{ translateX: -CONTROL_SEPARATION * (1 - separationProgress.value) }],  }));  const arrowContentStyle = useAnimatedStyle(() => ({    opacity: separationProgress.value,  }));   if (props.startedAt === null && !props.showScrollToEnd) {    return null;  }   return (    <Animated.View      pointerEvents="box-none"      className="absolute left-0 right-0 z-20 items-center"      style={{ top: -FLOATING_WORKING_CONTROL_COVERAGE }}      entering={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_ENTERING}      exiting={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_EXITING}    >      {props.startedAt !== null && NATIVE_LIQUID_GLASS_SUPPORTED ? (        <UniwindGlassContainer          spacing={GLASS_MERGE_SPACING}          pointerEvents="box-none"          className="flex-row items-center gap-4"        >          <AnimatedGlassView            colorScheme={props.colorScheme}            glassEffectStyle="regular"            pointerEvents="none"            className="h-11 justify-center overflow-hidden rounded-full"            style={timerStyle}          >            <WorkingDuration startedAt={props.startedAt} />          </AnimatedGlassView>           <AnimatedGlassView            colorScheme={props.colorScheme}            glassEffectStyle="regular"            isInteractive            pointerEvents={props.showScrollToEnd ? "auto" : "none"}            accessibilityElementsHidden={!props.showScrollToEnd}            importantForAccessibility={props.showScrollToEnd ? "auto" : "no-hide-descendants"}            className="h-11 w-11 items-center justify-center overflow-hidden rounded-full"            style={arrowTransformStyle}          >            <Animated.View style={arrowContentStyle}>              <ScrollToEndButton disabled={!props.showScrollToEnd} onPress={props.onScrollToEnd} />            </Animated.View>          </AnimatedGlassView>        </UniwindGlassContainer>      ) : props.startedAt !== null ? (        <View pointerEvents="box-none" className="flex-row items-center gap-4">          <Animated.View            pointerEvents="none"            className="h-11 justify-center rounded-full border border-border bg-card shadow-md shadow-black/10"            style={timerStyle}          >            <WorkingDuration startedAt={props.startedAt} />          </Animated.View>           <Animated.View            pointerEvents={props.showScrollToEnd ? "auto" : "none"}            accessibilityElementsHidden={!props.showScrollToEnd}            importantForAccessibility={props.showScrollToEnd ? "auto" : "no-hide-descendants"}            style={[arrowTransformStyle, arrowContentStyle]}          >            <ControlPill              accessibilityLabel="Scroll to end"              activateOnPressIn              className="h-11 w-11 border border-border bg-card shadow-md shadow-black/10"              disabled={!props.showScrollToEnd}              icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}              onPress={props.onScrollToEnd}            />          </Animated.View>        </View>      ) : NATIVE_LIQUID_GLASS_SUPPORTED ? (        <UniwindGlassView          colorScheme={props.colorScheme}          glassEffectStyle="regular"          isInteractive          className="h-11 w-11 items-center justify-center overflow-hidden rounded-full"        >          <ScrollToEndButton onPress={props.onScrollToEnd} />        </UniwindGlassView>      ) : (        <ControlPill          accessibilityLabel="Scroll to end"          activateOnPressIn          className="h-11 w-11 border border-border bg-card shadow-md shadow-black/10"          icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}          onPress={props.onScrollToEnd}        />      )}    </Animated.View>  );} function WorkingDuration(props: { readonly startedAt: string }) {  const [nowMs, setNowMs] = useState(() => Date.now());   useEffect(() => {    setNowMs(Date.now());    const intervalId = setInterval(() => setNowMs(Date.now()), 1_000);    return () => clearInterval(intervalId);  }, [props.startedAt]);   const duration = formatWorkingDuration(props.startedAt, nowMs);  const label = `Working for ${duration}`;   return (    <View accessible accessibilityLabel={label} className="h-11 flex-row items-center px-4">      <Text className="font-t3-medium text-xs text-foreground">Working for </Text>      <SystemText        className="text-xs text-foreground"        style={{ fontVariant: ["tabular-nums"], fontWeight: "500" }}      >        {duration}      </SystemText>    </View>  );} function formatWorkingDuration(startedAt: string, nowMs: number): string {  const startedAtMs = Date.parse(startedAt);  if (!Number.isFinite(startedAtMs) || nowMs <= startedAtMs) {    return "0s";  }   const totalSeconds = Math.floor((nowMs - startedAtMs) / 1_000);  if (totalSeconds < 60) {    return `${totalSeconds}s`;  }   const minutes = Math.floor(totalSeconds / 60);  const seconds = String(totalSeconds % 60).padStart(2, "0");  return `${minutes}m ${seconds}s`;} function ScrollToEndButton(props: { readonly disabled?: boolean; readonly onPress: () => void }) {  return (    <ControlPill      accessibilityLabel="Scroll to end"      activateOnPressIn      className="h-11 w-11 bg-transparent"      disabled={props.disabled}      icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}      onPress={props.onPress}    />  );}