diff --git a/src/components/ToastHost.tsx b/src/components/ToastHost.tsx
index 5d5f9088..1762398a 100644
--- a/src/components/ToastHost.tsx
+++ b/src/components/ToastHost.tsx
@@ -1,12 +1,16 @@
import React from 'react';
-import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
+import {
+ View, Text, StyleSheet, Pressable, Animated, Easing, PanResponder, useWindowDimensions,
+ type AccessibilityActionEvent, type LayoutChangeEvent,
+} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react-native';
-import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
-import { useColors } from '../theme/colors';
-import { useAnimDuration } from '../theme/dynamic';
+import { AlertCircle, AlertTriangle, CheckCircle2, Info } from 'lucide-react-native';
+import { spacing, radius, type ThemePalette } from '../theme/tokens';
+import { useColors, useResolvedTheme } from '../theme/colors';
+import { useShouldAnimate } from '../theme/dynamic';
import { useToastStore, type Toast } from '../stores/toast-store';
import { useLocaleStore } from '../stores/locale-store';
+import { shouldClaimToastSwipe, toastSwipeRelease } from './toast-swipe';
/**
* Renders the toast queue above the tab bar. App.tsx gives every stack screen
@@ -25,70 +29,196 @@ export function ToastHost(): React.ReactElement | null {
);
}
+const ENTER_MS = 200;
+const EXIT_MS = 160;
+
+// Icons and text in a status colour (repos/branding/APP.md): the palette's
+// error and warning fills are too light for an icon on a light ground.
+const STATUS_ICON = {
+ light: { error: '#dc2626', warning: '#d97706' },
+ dark: { error: '#f87171', warning: '#fbbf24' },
+} as const;
+
+/**
+ * The toast card from repos/branding/APP.md: a plain floating card whose icon
+ * and countdown line carry the status. No close button on the phone; a
+ * sideways swipe dismisses it.
+ */
function ToastCard({ toast }: { toast: Toast }) {
const c = useColors();
- const cardStyles = React.useMemo(() => makeStyles(c), [c]);
+ const scheme = useResolvedTheme();
+ const cardStyles = React.useMemo(() => makeStyles(c, scheme), [c, scheme]);
const t = useLocaleStore((s) => s.t);
const removeToast = useToastStore((s) => s.removeToast);
- const duration = useAnimDuration(200);
+ const animate = useShouldAnimate();
+ const { width: screenWidth } = useWindowDimensions();
+
const opacity = React.useRef(new Animated.Value(0)).current;
const translateY = React.useRef(new Animated.Value(24)).current;
+ const translateX = React.useRef(new Animated.Value(0)).current;
+ // Share of the countdown line still filled, 1 → 0.
+ const remainingShare = React.useRef(new Animated.Value(1)).current;
+ const cardWidth = React.useRef(0);
React.useEffect(() => {
- Animated.parallel([
+ const duration = animate ? ENTER_MS : 0;
+ const enter = Animated.parallel([
Animated.timing(opacity, { toValue: 1, duration, useNativeDriver: true }),
Animated.timing(translateY, { toValue: 0, duration, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
- ]).start();
+ ]);
+ enter.start();
+ return () => enter.stop();
+ }, [animate, opacity, translateY]);
+
+ // Leave after the toast's duration, counted from when it was queued (a
+ // screen change remounts the card). The countdown line runs out with it;
+ // with animations off it stays full instead of moving.
+ React.useEffect(() => {
const remaining = Math.max(0, toast.duration - (Date.now() - toast.createdAt));
const timer = setTimeout(() => removeToast(toast.id), remaining);
- return () => clearTimeout(timer);
- }, [toast, opacity, translateY, duration, removeToast]);
+ if (!animate) {
+ remainingShare.setValue(1);
+ return () => clearTimeout(timer);
+ }
+ remainingShare.setValue(toast.duration > 0 ? remaining / toast.duration : 0);
+ const countdown = Animated.timing(remainingShare, {
+ toValue: 0,
+ duration: remaining,
+ easing: Easing.linear,
+ useNativeDriver: true,
+ });
+ countdown.start();
+ return () => {
+ clearTimeout(timer);
+ countdown.stop();
+ };
+ }, [toast, animate, remainingShare, removeToast]);
- const accent = toast.type === 'success'
- ? c.success
- : toast.type === 'error'
- ? c.error
- : toast.type === 'warning'
- ? c.warning
- : c.info;
+ // The PanResponder is built once; its handlers read these through a ref.
+ const latest = React.useRef({ animate, screenWidth, id: toast.id, removeToast });
+ latest.current = { animate, screenWidth, id: toast.id, removeToast };
+
+ const responder = React.useMemo(
+ () => {
+ const springBack = () => {
+ if (!latest.current.animate) {
+ translateX.setValue(0);
+ return;
+ }
+ Animated.spring(translateX, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
+ };
+ return PanResponder.create({
+ onStartShouldSetPanResponder: () => false,
+ onMoveShouldSetPanResponder: (_, g) => shouldClaimToastSwipe(g),
+ onPanResponderGrant: () => translateX.stopAnimation(),
+ onPanResponderMove: (_, g) => translateX.setValue(g.dx),
+ onPanResponderRelease: (_, g) => {
+ const direction = toastSwipeRelease(g, cardWidth.current);
+ if (direction === 0) {
+ springBack();
+ return;
+ }
+ const { animate: animated, screenWidth: width, id, removeToast: remove } = latest.current;
+ Animated.timing(translateX, {
+ toValue: direction * width,
+ duration: animated ? EXIT_MS : 0,
+ useNativeDriver: true,
+ }).start(() => remove(id));
+ },
+ onPanResponderTerminate: springBack,
+ onPanResponderTerminationRequest: () => false,
+ });
+ },
+ [translateX],
+ );
+
+ // Fades as it is dragged away.
+ const cardOpacity = React.useMemo(
+ () => Animated.multiply(
+ opacity,
+ translateX.interpolate({
+ inputRange: [-screenWidth, 0, screenWidth],
+ outputRange: [0, 1, 0],
+ extrapolate: 'clamp',
+ }),
+ ),
+ [opacity, translateX, screenWidth],
+ );
+
+ const onLayout = (e: LayoutChangeEvent) => {
+ cardWidth.current = e.nativeEvent.layout.width;
+ };
+
+ const statusIcon = STATUS_ICON[scheme];
+ const iconColor = toast.type === 'error'
+ ? statusIcon.error
+ : toast.type === 'warning'
+ ? statusIcon.warning
+ : c.mutedForeground;
+ const countdownColor = toast.type === 'error'
+ ? c.error
+ : toast.type === 'warning'
+ ? c.warning
+ : c.mutedForeground;
const Icon = toast.type === 'success'
? CheckCircle2
: toast.type === 'error'
- ? XCircle
+ ? AlertCircle
: toast.type === 'warning'
? AlertTriangle
: Info;
+ // Without a close button, screen readers dismiss through an action.
+ const dismissActions = React.useMemo(
+ () => [{ name: 'dismiss', label: t('common.dismiss', 'Dismiss') }, { name: 'escape' }],
+ [t],
+ );
+ const onAccessibilityAction = (e: AccessibilityActionEvent) => {
+ if (e.nativeEvent.actionName === 'dismiss' || e.nativeEvent.actionName === 'escape') {
+ removeToast(toast.id);
+ }
+ };
+
return (
-
-
- {toast.title}
- {toast.message ? {toast.message} : null}
-
- {toast.action ? (
- { toast.action?.onPress(); removeToast(toast.id); }}
- hitSlop={8}
- accessibilityRole="button"
- style={({ pressed }) => [cardStyles.actionBtn, pressed && cardStyles.actionBtnPressed]}
+
+
+
- {toast.action.label}
-
- ) : null}
- removeToast(toast.id)}
- hitSlop={8}
- accessibilityRole="button"
- accessibilityLabel={t('common.dismiss', 'Dismiss')}
- style={cardStyles.close}
- >
-
-
+ {toast.title}
+ {toast.message ? {toast.message} : null}
+
+ {toast.action ? (
+ { toast.action?.onPress(); removeToast(toast.id); }}
+ hitSlop={8}
+ accessibilityRole="button"
+ style={({ pressed }) => [cardStyles.action, pressed && cardStyles.actionPressed]}
+ >
+ {/* The underline is a border so it can sit 3px under the
+ baseline; textDecorationLine has no offset in RN. */}
+
+ {toast.action.label}
+
+
+ ) : null}
+
+
+
);
}
@@ -103,31 +233,62 @@ const styles = StyleSheet.create({
},
});
-function makeStyles(c: ThemePalette) {
+const COUNTDOWN_HEIGHT = 2;
+
+function makeStyles(c: ThemePalette, scheme: 'light' | 'dark') {
return StyleSheet.create({
- card: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm,
- paddingVertical: spacing.sm + 2,
- paddingLeft: spacing.md,
- paddingRight: spacing.sm,
- borderRadius: radius.md,
- borderLeftWidth: 3,
+ // Carries the shadow; the card inside clips the countdown line to the
+ // rounded corners, which would clip an iOS shadow on the same view.
+ shadow: {
+ borderRadius: radius.sm,
backgroundColor: c.popover,
- borderWidth: 1,
- borderColor: c.border,
shadowColor: '#000',
- shadowOpacity: 0.25,
- shadowRadius: 10,
- shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: scheme === 'dark' ? 0.4 : 0.1,
+ shadowRadius: 16,
+ shadowOffset: { width: 0, height: 6 },
elevation: 6,
},
- title: { ...typography.bodyMedium, color: c.text },
- message: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
- actionBtn: { paddingHorizontal: spacing.sm, paddingVertical: 6, borderRadius: radius.sm },
- actionBtnPressed: { backgroundColor: c.muted },
- actionText: { ...typography.captionMedium },
- close: { padding: 4 },
+ card: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ gap: spacing.sm,
+ paddingTop: 12,
+ paddingLeft: 14,
+ paddingRight: 14,
+ // Room for the countdown line.
+ paddingBottom: 14,
+ borderRadius: radius.sm,
+ borderWidth: 1,
+ borderColor: c.border,
+ backgroundColor: c.popover,
+ overflow: 'hidden',
+ },
+ // Centred on the title's first line.
+ icon: { marginTop: 1 },
+ body: { flex: 1 },
+ title: { fontSize: 14.5, lineHeight: 20, fontWeight: '500', color: c.text },
+ message: { fontSize: 13, lineHeight: 18, color: c.mutedForeground, marginTop: 2 },
+ action: { alignSelf: 'center' },
+ actionPressed: { opacity: 0.6 },
+ actionUnderline: { borderBottomWidth: 1, borderBottomColor: c.text },
+ actionText: { fontSize: 13, lineHeight: 16, fontWeight: '600', color: c.text },
+ countdownTrack: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ bottom: 0,
+ height: COUNTDOWN_HEIGHT,
+ backgroundColor: c.text,
+ opacity: 0.06,
+ },
+ countdownFill: {
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ bottom: 0,
+ height: COUNTDOWN_HEIGHT,
+ opacity: 0.6,
+ transformOrigin: 'left',
+ },
});
}
diff --git a/src/components/__tests__/calendar-i18n.test.ts b/src/components/__tests__/calendar-i18n.test.ts
index da636db7..746a9f7b 100644
--- a/src/components/__tests__/calendar-i18n.test.ts
+++ b/src/components/__tests__/calendar-i18n.test.ts
@@ -11,6 +11,7 @@ const SRC = join(__dirname, '..', '..');
const TRANSLATED_FILES: Record = {
'screens/CalendarScreen.tsx': [],
'components/calendar/AgendaView.tsx': [],
+ 'components/calendar/EventBlock.tsx': [],
'components/calendar/EventCard.tsx': [],
'components/calendar/MonthScrollView.tsx': [],
'components/calendar/MonthView.tsx': [],
diff --git a/src/components/__tests__/toast-swipe.test.ts b/src/components/__tests__/toast-swipe.test.ts
new file mode 100644
index 00000000..9f836cdc
--- /dev/null
+++ b/src/components/__tests__/toast-swipe.test.ts
@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'vitest';
+import { shouldClaimToastSwipe, toastSwipeRelease } from '../toast-swipe';
+
+const g = (dx: number, dy = 0, vx = 0) => ({ dx, dy, vx });
+
+describe('shouldClaimToastSwipe', () => {
+ it('takes sideways drags in either direction', () => {
+ expect(shouldClaimToastSwipe(g(10))).toBe(true);
+ expect(shouldClaimToastSwipe(g(-10, 4))).toBe(true);
+ });
+
+ it('leaves taps, jitter and vertical moves alone', () => {
+ expect(shouldClaimToastSwipe(g(3))).toBe(false);
+ expect(shouldClaimToastSwipe(g(10, 10))).toBe(false);
+ expect(shouldClaimToastSwipe(g(0, 30))).toBe(false);
+ });
+});
+
+describe('toastSwipeRelease', () => {
+ it('dismisses past 35% of the card, towards the drag', () => {
+ expect(toastSwipeRelease(g(120), 300)).toBe(1);
+ expect(toastSwipeRelease(g(-110), 300)).toBe(-1);
+ expect(toastSwipeRelease(g(100), 300)).toBe(0);
+ });
+
+ it('caps the distance on wide cards', () => {
+ expect(toastSwipeRelease(g(125), 800)).toBe(1);
+ expect(toastSwipeRelease(g(115), 800)).toBe(0);
+ });
+
+ it('dismisses a short flick', () => {
+ expect(toastSwipeRelease(g(40, 0, 0.8), 300)).toBe(1);
+ expect(toastSwipeRelease(g(-40, 0, -0.8), 300)).toBe(-1);
+ // A flick against the drag does not count.
+ expect(toastSwipeRelease(g(40, 0, -0.8), 300)).toBe(0);
+ expect(toastSwipeRelease(g(20, 0, 2), 300)).toBe(0);
+ });
+
+ it('does not dismiss a small drag before the card has a width', () => {
+ expect(toastSwipeRelease(g(20), 0)).toBe(0);
+ expect(toastSwipeRelease(g(130), 0)).toBe(1);
+ });
+});
diff --git a/src/components/calendar/AgendaView.tsx b/src/components/calendar/AgendaView.tsx
index 2adc37eb..60a63ade 100644
--- a/src/components/calendar/AgendaView.tsx
+++ b/src/components/calendar/AgendaView.tsx
@@ -47,6 +47,8 @@ interface AgendaViewProps {
/** Reports the first day in view as the user scrolls. */
onVisibleDateChange?: (date: Date) => void;
timeFormat?: TimeFormat;
+ /** The user's addresses, to draw events they declined as inactive. */
+ currentUserEmails?: string[];
onSelectEvent?: (event: CalendarEvent) => void;
}
@@ -90,6 +92,7 @@ export function AgendaView({
isLoading = false,
onVisibleDateChange,
timeFormat,
+ currentUserEmails,
onSelectEvent,
}: AgendaViewProps) {
const c = useColors();
@@ -324,7 +327,13 @@ export function AgendaView({
}}
renderItem={({ item }) => (
-
+
)}
renderSectionFooter={({ section }) =>
diff --git a/src/components/calendar/EventBlock.tsx b/src/components/calendar/EventBlock.tsx
new file mode 100644
index 00000000..46e3df8c
--- /dev/null
+++ b/src/components/calendar/EventBlock.tsx
@@ -0,0 +1,169 @@
+import React from 'react';
+import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
+import type { EventBlockColors } from '../../lib/event-colors';
+
+// Events in the week and day grids, shared by the paged WeekView and the
+// scrolling TimeGridScrollView. Every event is a solid block of its calendar
+// colour with a label colour computed from it; declined and cancelled ones
+// are outlined on the page ground with a struck-through title
+// (repos/branding/APP.md, "Calendar events"). Callers get the colours from
+// eventBlockColors().
+
+/** Corner radius of event blocks and bars. */
+export const EVENT_RADIUS = 2;
+
+/** Timed blocks this long or longer show their start time under the title. */
+export const TIME_LINE_MIN_MINUTES = 45;
+
+// Corners on a side the event continues past (the day before or after, the
+// week before or after) stay square.
+function corners(
+ axis: 'vertical' | 'horizontal',
+ continuesBefore: boolean,
+ continuesAfter: boolean,
+ r: number,
+): ViewStyle {
+ const before = continuesBefore ? 0 : r;
+ const after = continuesAfter ? 0 : r;
+ return axis === 'vertical'
+ ? {
+ borderTopLeftRadius: before,
+ borderTopRightRadius: before,
+ borderBottomLeftRadius: after,
+ borderBottomRightRadius: after,
+ }
+ : {
+ borderTopLeftRadius: before,
+ borderBottomLeftRadius: before,
+ borderTopRightRadius: after,
+ borderBottomRightRadius: after,
+ };
+}
+
+interface TimedEventBlockProps {
+ title: string;
+ /** Start time for the second line; null leaves it out. */
+ timeLabel: string | null;
+ colors: EventBlockColors;
+ /** Page ground: a 1px ring in it keeps overlapping blocks apart. */
+ ringColor: string;
+ /** The event started on an earlier day (square top corners). */
+ continuesBefore: boolean;
+ /** The event runs into the next day (square bottom corners). */
+ continuesAfter: boolean;
+ /** Position and size in the day column. */
+ style: StyleProp;
+ onPress?: () => void;
+}
+
+/** A timed event in a day column. */
+export function TimedEventBlock({
+ title,
+ timeLabel,
+ colors,
+ ringColor,
+ continuesBefore,
+ continuesAfter,
+ style,
+ onPress,
+}: TimedEventBlockProps) {
+ const inactive = colors.border !== null;
+ return (
+
+ {inactive && (
+ // The calendar-coloured outline sits inside the ring.
+
+ )}
+
+ {title}
+
+ {timeLabel !== null && (
+
+ {timeLabel}
+
+ )}
+
+ );
+}
+
+interface AllDayEventBarProps {
+ title: string;
+ colors: EventBlockColors;
+ /** The event runs in from the week before (square left corners). */
+ continuesBefore: boolean;
+ /** The event runs on into the week after (square right corners). */
+ continuesAfter: boolean;
+ /** Position and size in the all-day strip. */
+ style: StyleProp;
+ onPress?: () => void;
+}
+
+/** An all-day or multi-day event in the strip above the grid. */
+export function AllDayEventBar({
+ title,
+ colors,
+ continuesBefore,
+ continuesAfter,
+ style,
+ onPress,
+}: AllDayEventBarProps) {
+ const inactive = colors.border !== null;
+ return (
+
+
+ {continuesBefore ? '… ' : ''}
+ {title}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ block: {
+ position: 'absolute',
+ borderWidth: 1,
+ paddingHorizontal: 3,
+ paddingVertical: 1,
+ overflow: 'hidden',
+ },
+ outline: { ...StyleSheet.absoluteFillObject, borderWidth: 1 },
+ blockTitle: { fontSize: 12, lineHeight: 15, fontWeight: '500' },
+ blockTime: { fontSize: 10.5, lineHeight: 13, fontWeight: '400', opacity: 0.85 },
+ struck: { textDecorationLine: 'line-through' },
+ bar: {
+ position: 'absolute',
+ paddingHorizontal: 4,
+ justifyContent: 'center',
+ overflow: 'hidden',
+ },
+ // The outline takes the place of a pixel of padding.
+ barInactive: { borderWidth: 1, paddingHorizontal: 3 },
+ barTitle: { fontSize: 12, lineHeight: 15, fontWeight: '500' },
+});
diff --git a/src/components/calendar/EventCard.tsx b/src/components/calendar/EventCard.tsx
index ddc71ce0..3a461793 100644
--- a/src/components/calendar/EventCard.tsx
+++ b/src/components/calendar/EventCard.tsx
@@ -12,11 +12,14 @@ import {
type TimeFormat,
} from '../../lib/calendar-utils';
import { useCalendarLocale } from '../../lib/calendar-locale';
+import { isInactiveEvent } from '../../lib/calendar-participants';
interface EventCardProps {
event: CalendarEvent;
calendars: Calendar[];
timeFormat?: TimeFormat;
+ /** The user's addresses, to draw events they declined as inactive. */
+ currentUserEmails?: string[];
onPress?: (event: CalendarEvent) => void;
onLongPress?: (event: CalendarEvent) => void;
}
@@ -25,7 +28,12 @@ function participantCount(event: CalendarEvent): number {
return event.participants ? Object.keys(event.participants).length : 0;
}
-export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }: EventCardProps) {
+/**
+ * An event as an agenda row (repos/branding/APP.md): a dot in the calendar
+ * colour before the title, no card and no bar. Declined and cancelled events
+ * get a hollow dot and a struck-through, muted title.
+ */
+export function EventCard({ event, calendars, timeFormat, currentUserEmails, onPress, onLongPress }: EventCardProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const { locale, t } = useCalendarLocale();
@@ -37,18 +45,18 @@ export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }
: `${format(start, fmt, { locale })} – ${format(end, fmt, { locale })}`;
const count = participantCount(event);
const location = event.locations ? Object.values(event.locations)[0]?.name : undefined;
- const cancelled = event.status === 'cancelled';
+ const inactive = isInactiveEvent(event, currentUserEmails);
return (
[styles.card, pressed && styles.cardPressed]}
+ style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => onPress?.(event)}
onLongPress={() => onLongPress?.(event)}
>
-
+
-
+
{event.title || t('calendar.events.no_title', '(No title)')}
{event.showWithoutTime && (
@@ -89,21 +97,31 @@ export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }
);
}
+const EVENT_DOT = 9;
+
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
- card: {
+ // Bleeds into the list's side padding so the dot lines up with the day
+ // headers while the pressed highlight keeps some room around the text.
+ row: {
flexDirection: 'row',
- backgroundColor: c.card,
- borderTopRightRadius: radius.lg,
- borderBottomRightRadius: radius.lg,
- marginBottom: spacing.sm,
- overflow: 'hidden',
- borderWidth: 1,
- borderColor: c.border,
+ alignItems: 'flex-start',
+ gap: spacing.sm,
+ paddingVertical: spacing.sm,
+ paddingHorizontal: spacing.sm,
+ marginHorizontal: -spacing.sm,
+ borderRadius: radius.sm,
},
- cardPressed: { backgroundColor: c.surfaceHover },
- colorBar: { width: 3 },
- body: { flex: 1, padding: spacing.md, gap: 4 },
+ rowPressed: { backgroundColor: c.surfaceHover },
+ // Centred on the title's line (bodyMedium, 20px).
+ dot: {
+ width: EVENT_DOT,
+ height: EVENT_DOT,
+ borderRadius: EVENT_DOT / 2,
+ marginTop: (typography.bodyMedium.lineHeight - EVENT_DOT) / 2,
+ },
+ dotHollow: { borderWidth: 1.5 },
+ body: { flex: 1, gap: 4 },
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
@@ -111,7 +129,7 @@ function makeStyles(c: ThemePalette) {
gap: spacing.sm,
},
title: { ...typography.bodyMedium, color: c.text, flex: 1 },
- titleCancelled: { textDecorationLine: 'line-through', color: c.textMuted },
+ titleInactive: { textDecorationLine: 'line-through', color: c.mutedForeground },
allDayBadge: {
backgroundColor: c.primaryBg,
paddingHorizontal: 8,
diff --git a/src/components/calendar/MonthScrollView.tsx b/src/components/calendar/MonthScrollView.tsx
index 5484273f..275e7895 100644
--- a/src/components/calendar/MonthScrollView.tsx
+++ b/src/components/calendar/MonthScrollView.tsx
@@ -53,6 +53,8 @@ interface MonthScrollViewProps {
showWeekNumbers?: boolean;
showTimeInMonthView?: boolean;
timeFormat?: TimeFormat;
+ /** The user's addresses, to draw events they declined as inactive. */
+ currentUserEmails?: string[];
/** Widen the window at the top; omitted once the limit is reached. */
onExtendStart?: () => void;
/** Widen the window at the bottom; omitted once the limit is reached. */
@@ -83,6 +85,7 @@ function MonthScrollViewInner({
showWeekNumbers = false,
showTimeInMonthView = false,
timeFormat,
+ currentUserEmails,
onExtendStart,
onExtendEnd,
onVisibleDateChange,
@@ -191,6 +194,7 @@ function MonthScrollViewInner({
showWeekNumbers={showWeekNumbers}
showTimeInMonthView={showTimeInMonthView}
timeFormat={timeFormat}
+ currentUserEmails={currentUserEmails}
labelMonths
height={rowHeight}
locale={locale}
@@ -209,6 +213,7 @@ function MonthScrollViewInner({
showWeekNumbers,
showTimeInMonthView,
timeFormat,
+ currentUserEmails,
rowHeight,
locale,
styles,
diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx
index 41bf16de..a31ac81a 100644
--- a/src/components/calendar/MonthView.tsx
+++ b/src/components/calendar/MonthView.tsx
@@ -12,7 +12,7 @@ import {
type Locale,
} from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
-import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
+import { componentSizes, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import {
buildEventDayIndex,
@@ -24,6 +24,8 @@ import {
type TimeFormat,
} from '../../lib/calendar-utils';
import { dayIndexIn, monthKeyOf, monthMask } from '../../lib/calendar-month-scroll';
+import { isInactiveEvent } from '../../lib/calendar-participants';
+import { eventBlockColors } from '../../lib/event-colors';
import { useCalendarLocale } from '../../lib/calendar-locale';
import { displayNow } from '../../lib/calendar-timezone';
@@ -33,6 +35,8 @@ type WeekStart = 0 | 1 | 6;
// scrolling month uses them as fixed row heights.
export const MONTH_ROW_HEIGHT = 54;
export const MONTH_ROW_HEIGHT_CHIPS = 74;
+// Corner radius of event bars (repos/branding/APP.md).
+const EVENT_RADIUS = 2;
interface MonthViewProps {
currentDate: Date;
@@ -45,6 +49,8 @@ interface MonthViewProps {
// Render compact event chips (title + start time) instead of dots (#666).
showTimeInMonthView?: boolean;
timeFormat?: TimeFormat;
+ /** The user's addresses, to draw events they declined as inactive. */
+ currentUserEmails?: string[];
onSelectDate: (date: Date) => void;
onLongPressDate?: (date: Date) => void;
}
@@ -101,6 +107,8 @@ export interface MonthWeekRowProps {
showWeekNumbers: boolean;
showTimeInMonthView: boolean;
timeFormat?: TimeFormat;
+ /** The user's addresses, to draw events they declined as inactive. */
+ currentUserEmails?: string[];
/** Mark the first day of a month with the month's name (continuous scrolling). */
labelMonths?: boolean;
/** Fixed row height (continuous scrolling); natural height otherwise. */
@@ -122,6 +130,7 @@ function MonthWeekRowInner({
showWeekNumbers,
showTimeInMonthView,
timeFormat,
+ currentUserEmails,
labelMonths = false,
height,
locale,
@@ -129,6 +138,7 @@ function MonthWeekRowInner({
onSelectDate,
onLongPressDate,
}: MonthWeekRowProps) {
+ const c = useColors();
return (
{showWeekNumbers && (
@@ -197,17 +207,24 @@ function MonthWeekRowInner({
{dayEvents.length > 0 && showTimeInMonthView && (
{visible.map((event, idx) => {
- const color = getEventColor(event, calendars);
- const cancelled = event.status === 'cancelled';
+ // Solid bars in the calendar colour; declined and
+ // cancelled events outlined (repos/branding/APP.md).
+ const inactive = isInactiveEvent(event, currentUserEmails);
+ const colors = eventBlockColors(getEventColor(event, calendars), inactive, c);
return (
-
-
+
+
{event.showWithoutTime
- ? (event.title || '')
- : `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} ${event.title || ''}`}
+ ? null
+ : `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} `}
+ {event.title || ''}
);
@@ -236,6 +253,7 @@ function MonthViewInner({
showWeekNumbers = false,
showTimeInMonthView = false,
timeFormat,
+ currentUserEmails,
onSelectDate,
onLongPressDate,
}: MonthViewProps) {
@@ -278,6 +296,7 @@ function MonthViewInner({
showWeekNumbers={showWeekNumbers}
showTimeInMonthView={showTimeInMonthView}
timeFormat={timeFormat}
+ currentUserEmails={currentUserEmails}
locale={locale}
styles={styles}
onSelectDate={onSelectDate}
@@ -349,12 +368,17 @@ function makeStyles(c: ThemePalette) {
},
chipsCol: { width: '100%', gap: 1, marginTop: 2, alignItems: 'stretch' },
chip: {
- borderRadius: radius.xs,
+ borderRadius: EVENT_RADIUS,
paddingHorizontal: 2,
paddingVertical: 1,
},
- chipText: { color: c.textInverse, fontSize: 9, lineHeight: 11 },
- chipTextCancelled: { textDecorationLine: 'line-through' },
+ // The 1px outline takes the place of a pixel of padding, so outlined and
+ // solid bars are the same size.
+ chipInactive: { borderWidth: 1, paddingHorizontal: 1, paddingVertical: 0 },
+ // Colour comes from eventBlockColors(): computed from the calendar colour,
+ // never a theme colour.
+ chipText: { fontSize: 9, lineHeight: 11, fontWeight: '500' },
+ chipTextInactive: { textDecorationLine: 'line-through' },
overflowText: {
color: c.textMuted,
fontSize: 9,
diff --git a/src/components/calendar/TimeGridScrollView.tsx b/src/components/calendar/TimeGridScrollView.tsx
index 3b0cbc02..8ef195be 100644
--- a/src/components/calendar/TimeGridScrollView.tsx
+++ b/src/components/calendar/TimeGridScrollView.tsx
@@ -17,7 +17,7 @@ import {
} from 'react-native';
import { differenceInCalendarDays, format, type Locale } from 'date-fns';
import type { Calendar, CalendarEvent } from '../../api/types';
-import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
+import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useCalendarLocale } from '../../lib/calendar-locale';
import {
@@ -33,6 +33,8 @@ import {
} from '../../lib/calendar-utils';
import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window';
import { displayNow, displayNowMinutes } from '../../lib/calendar-timezone';
+import { isInactiveEvent } from '../../lib/calendar-participants';
+import { eventBlockColors } from '../../lib/event-colors';
import {
buildAllDaySegments,
headerColumnRange,
@@ -41,6 +43,7 @@ import {
windowDays,
type TimeGridMode,
} from '../../lib/calendar-time-grid';
+import { AllDayEventBar, TIME_LINE_MIN_MINUTES, TimedEventBlock } from './EventBlock';
const HOUR_HEIGHT = 48;
const GRID_HEIGHT = 24 * HOUR_HEIGHT;
@@ -67,6 +70,8 @@ interface TimeGridScrollViewProps {
eventsByDay?: EventDayIndex;
weekStartsOn?: WeekStart;
timeFormat?: TimeFormat;
+ /** The user's addresses, to draw events they declined as inactive. */
+ currentUserEmails?: string[];
/** Widen the window at the start; omitted once the limit is reached. */
onExtendStart?: () => void;
/** Widen the window at the end; omitted once the limit is reached. */
@@ -98,6 +103,7 @@ function TimeGridScrollViewInner({
eventsByDay,
weekStartsOn = 0,
timeFormat = '24h',
+ currentUserEmails,
onExtendStart,
onExtendEnd,
onVisibleDateChange,
@@ -301,12 +307,17 @@ function TimeGridScrollViewInner({
nowMinutes={dayKey(day) === todayKey ? nowMinutes : -1}
calendars={calendars}
timeFormat={timeFormat}
+ currentUserEmails={currentUserEmails}
+ colors={c}
styles={styles}
onSelectEvent={onSelectEvent}
onLongPressAt={onCreateAtTime ? handleLongPressAt : undefined}
/>
),
- [layoutsFor, colWidth, todayKey, nowMinutes, calendars, timeFormat, styles, onSelectEvent, onCreateAtTime, handleLongPressAt],
+ [
+ layoutsFor, colWidth, todayKey, nowMinutes, calendars, timeFormat, currentUserEmails, c, styles,
+ onSelectEvent, onCreateAtTime, handleLongPressAt,
+ ],
);
const getItemLayout = React.useCallback(
@@ -379,34 +390,26 @@ function TimeGridScrollViewInner({
]}
>
{headerCells}
- {visibleSegments.map((segment) => {
- const color = getEventColor(segment.event, calendars);
- return (
- onSelectEvent?.(segment.event)}
- style={[
- styles.allDayChip,
- {
- left: segment.startIndex * colWidth + 1,
- width: segment.span * colWidth - 2,
- top: HEADER_HEIGHT + 2 + segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP),
- backgroundColor: color + '33',
- borderLeftColor: color,
- borderTopLeftRadius: segment.continuesBefore ? 0 : radius.xs,
- borderBottomLeftRadius: segment.continuesBefore ? 0 : radius.xs,
- borderTopRightRadius: segment.continuesAfter ? 0 : radius.xs,
- borderBottomRightRadius: segment.continuesAfter ? 0 : radius.xs,
- },
- ]}
- >
-
- {segment.continuesBefore ? '… ' : ''}
- {segment.event.title || t('calendar.events.no_title', '(No title)')}
-
-
- );
- })}
+ {visibleSegments.map((segment) => (
+ onSelectEvent?.(segment.event)}
+ style={{
+ left: segment.startIndex * colWidth + 1,
+ width: segment.span * colWidth - 2,
+ top: HEADER_HEIGHT + 2 + segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP),
+ height: ALL_DAY_CHIP_HEIGHT,
+ }}
+ />
+ ))}
@@ -526,6 +529,8 @@ const DayColumn = React.memo(function DayColumn({
nowMinutes,
calendars,
timeFormat,
+ currentUserEmails,
+ colors,
styles,
onSelectEvent,
onLongPressAt,
@@ -537,6 +542,8 @@ const DayColumn = React.memo(function DayColumn({
nowMinutes: number;
calendars: Calendar[];
timeFormat: TimeFormat;
+ currentUserEmails?: string[];
+ colors: ThemePalette;
styles: GridStyles;
onSelectEvent?: (event: CalendarEvent) => void;
onLongPressAt?: (day: Date, hour: number) => void;
@@ -553,36 +560,28 @@ const DayColumn = React.memo(function DayColumn({
style={[styles.dayCol, { width }]}
onLongPress={onLongPressAt ? handleLongPress : undefined}
>
- {layouts.map(({ event, column, totalColumns, startMinutes, endMinutes }) => {
+ {layouts.map(({ event, column, totalColumns, startMinutes, endMinutes, continuesBefore, continuesAfter }) => {
const top = (startMinutes / 60) * HOUR_HEIGHT;
const height = Math.max(20, ((endMinutes - startMinutes) / 60) * HOUR_HEIGHT - 1);
const widthPct = 100 / totalColumns;
- const color = getEventColor(event, calendars);
return (
- onSelectEvent?.(event)}
- style={[
- styles.eventBlock,
- {
- top,
- height,
- left: `${column * widthPct}%`,
- width: `${widthPct}%`,
- backgroundColor: color + '33',
- borderLeftColor: color,
- },
- ]}
- >
-
- {event.title || t('calendar.events.no_title', '(No title)')}
-
- {height > 32 && (
-
- {minutesToTimeLabel(startMinutes, timeFormat)}
-
+ title={event.title || t('calendar.events.no_title', '(No title)')}
+ timeLabel={endMinutes - startMinutes >= TIME_LINE_MIN_MINUTES
+ ? minutesToTimeLabel(startMinutes, timeFormat)
+ : null}
+ colors={eventBlockColors(
+ getEventColor(event, calendars),
+ isInactiveEvent(event, currentUserEmails),
+ colors,
)}
-
+ ringColor={colors.background}
+ continuesBefore={continuesBefore}
+ continuesAfter={continuesAfter}
+ onPress={() => onSelectEvent?.(event)}
+ style={{ top, height, left: `${column * widthPct}%`, width: `${widthPct}%` }}
+ />
);
})}
{nowMinutes >= 0 && (
@@ -649,14 +648,6 @@ function makeStyles(c: ThemePalette) {
allDayLabelWrap: { alignItems: 'flex-end', justifyContent: 'flex-start', paddingRight: 4, paddingTop: 2 },
allDayLabel: { ...typography.small, color: c.textMuted, textAlign: 'right' },
- allDayChip: {
- position: 'absolute',
- height: ALL_DAY_CHIP_HEIGHT,
- borderLeftWidth: 2,
- paddingHorizontal: 4,
- justifyContent: 'center',
- },
- allDayChipText: { fontSize: 10, color: c.text, fontWeight: '500' },
vScroll: { flex: 1 },
gridRow: { flexDirection: 'row', height: GRID_HEIGHT },
@@ -678,16 +669,6 @@ function makeStyles(c: ThemePalette) {
borderLeftWidth: 1,
borderLeftColor: c.border,
},
- eventBlock: {
- position: 'absolute',
- borderRadius: radius.xs,
- borderLeftWidth: 2,
- paddingHorizontal: 3,
- paddingVertical: 2,
- overflow: 'hidden',
- },
- eventBlockTitle: { fontSize: 10, color: c.text, fontWeight: '600' },
- eventBlockTime: { fontSize: 9, color: c.textMuted, marginTop: 1 },
nowLine: {
position: 'absolute',
left: -3,
diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx
index a35bb40f..ed2f7b28 100644
--- a/src/components/calendar/WeekView.tsx
+++ b/src/components/calendar/WeekView.tsx
@@ -9,7 +9,7 @@ import {
import { addDays, format, isSameDay, startOfWeek } from 'date-fns';
import { displayNow, displayNowMinutes, isDisplayToday } from '../../lib/calendar-timezone';
import type { Calendar, CalendarEvent } from '../../api/types';
-import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
+import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useCalendarLocale } from '../../lib/calendar-locale';
import {
@@ -24,6 +24,9 @@ import {
type CalendarWeekSegment,
type EventDayIndex,
} from '../../lib/calendar-utils';
+import { isInactiveEvent } from '../../lib/calendar-participants';
+import { eventBlockColors } from '../../lib/event-colors';
+import { AllDayEventBar, TIME_LINE_MIN_MINUTES, TimedEventBlock } from './EventBlock';
const HOUR_HEIGHT = 48;
const GUTTER_WIDTH = 44;
@@ -43,6 +46,8 @@ interface WeekViewProps {
onCreateAtTime?: (date: Date) => void;
weekStartsOn?: 0 | 1 | 6;
timeFormat?: '12h' | '24h';
+ /** The user's addresses, to draw events they declined as inactive. */
+ currentUserEmails?: string[];
}
function WeekViewInner({
@@ -56,6 +61,7 @@ function WeekViewInner({
onCreateAtTime,
weekStartsOn = 0,
timeFormat = '24h',
+ currentUserEmails,
}: WeekViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -183,35 +189,28 @@ function WeekViewInner({
{/* Segments overlay - multi-day events span their date range */}
{allDaySegments.map((segment) => {
- const color = getEventColor(segment.event, calendars);
const leftPct = (segment.startIndex / weekDays.length) * 100;
const widthPct = (segment.span / weekDays.length) * 100;
return (
- onSelectEvent?.(segment.event)}
- style={[
- styles.allDayChip,
- {
- position: 'absolute',
- left: `${leftPct}%`,
- width: `${widthPct}%`,
- top: segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP),
- backgroundColor: color + '33',
- borderLeftColor: color,
- borderTopLeftRadius: segment.continuesBefore ? 0 : radius.xs,
- borderBottomLeftRadius: segment.continuesBefore ? 0 : radius.xs,
- borderTopRightRadius: segment.continuesAfter ? 0 : radius.xs,
- borderBottomRightRadius: segment.continuesAfter ? 0 : radius.xs,
- marginHorizontal: 1,
- },
- ]}
- >
-
- {segment.continuesBefore ? '… ' : ''}
- {segment.event.title || t('calendar.events.no_title', '(No title)')}
-
-
+ style={{
+ left: `${leftPct}%`,
+ width: `${widthPct}%`,
+ top: segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP),
+ height: ALL_DAY_CHIP_HEIGHT,
+ marginHorizontal: 1,
+ }}
+ />
);
})}
@@ -255,7 +254,7 @@ function WeekViewInner({
))}
{layouted.map(
- ({ event, column, totalColumns, startMinutes, endMinutes }) => {
+ ({ event, column, totalColumns, startMinutes, endMinutes, continuesBefore, continuesAfter }) => {
const top = (startMinutes / 60) * HOUR_HEIGHT;
const height = Math.max(
20,
@@ -263,32 +262,24 @@ function WeekViewInner({
);
const widthPct = 100 / totalColumns;
const leftPct = column * widthPct;
- const color = getEventColor(event, calendars);
return (
- onSelectEvent?.(event)}
- style={[
- styles.eventBlock,
- {
- top,
- height,
- left: `${leftPct}%`,
- width: `${widthPct}%`,
- backgroundColor: color + '33',
- borderLeftColor: color,
- },
- ]}
- >
-
- {event.title || t('calendar.events.no_title', '(No title)')}
-
- {height > 32 && (
-
- {minutesToTimeLabel(startMinutes, timeFormat)}
-
+ title={event.title || t('calendar.events.no_title', '(No title)')}
+ timeLabel={endMinutes - startMinutes >= TIME_LINE_MIN_MINUTES
+ ? minutesToTimeLabel(startMinutes, timeFormat)
+ : null}
+ colors={eventBlockColors(
+ getEventColor(event, calendars),
+ isInactiveEvent(event, currentUserEmails),
+ c,
)}
-
+ ringColor={c.background}
+ continuesBefore={continuesBefore}
+ continuesAfter={continuesAfter}
+ onPress={() => onSelectEvent?.(event)}
+ style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%` }}
+ />
);
},
)}
@@ -389,14 +380,6 @@ function makeStyles(c: ThemePalette) {
top: 0,
bottom: 0,
},
- allDayChip: {
- height: ALL_DAY_CHIP_HEIGHT,
- borderRadius: radius.xs,
- borderLeftWidth: 2,
- paddingHorizontal: 4,
- justifyContent: 'center',
- },
- allDayChipText: { fontSize: 10, color: c.text, fontWeight: '500' },
scroll: { flex: 1 },
scrollContent: {},
@@ -419,16 +402,6 @@ function makeStyles(c: ThemePalette) {
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
- eventBlock: {
- position: 'absolute',
- borderRadius: radius.xs,
- borderLeftWidth: 2,
- paddingHorizontal: 3,
- paddingVertical: 2,
- overflow: 'hidden',
- },
- eventBlockTitle: { fontSize: 10, color: c.text, fontWeight: '600' },
- eventBlockTime: { fontSize: 9, color: c.textMuted, marginTop: 1 },
nowLine: {
position: 'absolute',
diff --git a/src/components/toast-swipe.ts b/src/components/toast-swipe.ts
new file mode 100644
index 00000000..ff9e31f5
--- /dev/null
+++ b/src/components/toast-swipe.ts
@@ -0,0 +1,39 @@
+// Pure decisions behind the toast's swipe-to-dismiss. On the phone a toast has
+// no close button; swiping it sideways dismisses it (repos/branding/APP.md).
+// Like swipe-gesture.ts for list rows, this sits outside the component
+// because the PanResponder is built once: everything it decides on arrives as
+// an argument, so nothing can go stale.
+
+/** The subset of RN's PanResponder gesture state this logic needs. */
+export interface ToastSwipeGesture {
+ dx: number;
+ dy: number;
+ vx: number;
+}
+
+const MIN_DX_TO_CLAIM = 6;
+const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor
+// A release past this share of the card's width dismisses it, capped so a
+// wide screen does not ask for a long drag.
+const DISMISS_FRACTION = 0.35;
+const MAX_DISMISS_DISTANCE = 120;
+// A short, fast flick dismisses too.
+const FLING_DX = 30;
+const FLING_VX = 0.5;
+
+/** Whether a move is a sideways drag the toast should take over. */
+export function shouldClaimToastSwipe(g: ToastSwipeGesture): boolean {
+ if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
+ return Math.abs(g.dx) >= Math.abs(g.dy) * DIRECTION_BIAS;
+}
+
+/**
+ * What releasing the drag does: 1 or -1 dismisses the toast towards that
+ * side, 0 springs it back. `width` is the card's width (0 before layout).
+ */
+export function toastSwipeRelease(g: ToastSwipeGesture, width: number): 1 | -1 | 0 {
+ const distance = width > 0 ? Math.min(MAX_DISMISS_DISTANCE, width * DISMISS_FRACTION) : MAX_DISMISS_DISTANCE;
+ if (g.dx >= distance || (g.dx > FLING_DX && g.vx >= FLING_VX)) return 1;
+ if (g.dx <= -distance || (g.dx < -FLING_DX && g.vx <= -FLING_VX)) return -1;
+ return 0;
+}
diff --git a/src/lib/__tests__/calendar-participants.test.ts b/src/lib/__tests__/calendar-participants.test.ts
index a5819ec1..6573d9dd 100644
--- a/src/lib/__tests__/calendar-participants.test.ts
+++ b/src/lib/__tests__/calendar-participants.test.ts
@@ -7,6 +7,8 @@ import {
getParticipantList,
getStatusCounts,
getUserParticipantId,
+ isDeclinedByUser,
+ isInactiveEvent,
isOrganizer,
seedAttendees,
} from '../calendar-participants';
@@ -171,3 +173,31 @@ describe('getParticipantList dedupe, names and organizer status', () => {
})).toBe(3);
});
});
+
+describe('isDeclinedByUser / isInactiveEvent', () => {
+ const declined: Partial = {
+ participants: {
+ org: { email: 'alice@example.com', roles: { owner: true } },
+ dave: { calendarAddress: 'mailto:dave@example.com', roles: { attendee: true }, participationStatus: 'declined' },
+ bob: { email: 'bob@example.com', roles: { attendee: true }, participationStatus: 'accepted' },
+ },
+ };
+
+ it('matches any of the user addresses, case-insensitively', () => {
+ expect(isDeclinedByUser(declined, ['Dave@Example.com'])).toBe(true);
+ expect(isDeclinedByUser(declined, ['alias@example.com', 'dave@example.com'])).toBe(true);
+ expect(isDeclinedByUser(declined, ['bob@example.com'])).toBe(false);
+ });
+
+ it('is false without addresses or participants', () => {
+ expect(isDeclinedByUser(declined, [])).toBe(false);
+ expect(isDeclinedByUser(declined, undefined)).toBe(false);
+ expect(isDeclinedByUser({}, ['dave@example.com'])).toBe(false);
+ });
+
+ it('counts cancelled events as inactive too', () => {
+ expect(isInactiveEvent({ status: 'cancelled' }, undefined)).toBe(true);
+ expect(isInactiveEvent(declined, ['dave@example.com'])).toBe(true);
+ expect(isInactiveEvent({ ...declined, status: 'confirmed' }, ['bob@example.com'])).toBe(false);
+ });
+});
diff --git a/src/lib/__tests__/event-colors.test.ts b/src/lib/__tests__/event-colors.test.ts
new file mode 100644
index 00000000..4ae6f07b
--- /dev/null
+++ b/src/lib/__tests__/event-colors.test.ts
@@ -0,0 +1,116 @@
+import { describe, it, expect } from 'vitest';
+import {
+ DARK_EVENT_TEXT,
+ eventBlockColors,
+ inactiveEventTextColor,
+ mixColors,
+ parseColor,
+ readableTextOn,
+} from '../event-colors';
+
+describe('parseColor', () => {
+ it('reads the hex forms', () => {
+ expect(parseColor('#abc')).toEqual({ r: 170, g: 187, b: 204, a: 1 });
+ expect(parseColor('#3B82F6')).toEqual({ r: 59, g: 130, b: 246, a: 1 });
+ expect(parseColor('#3b82f680')).toEqual({ r: 59, g: 130, b: 246, a: 128 / 255 });
+ expect(parseColor('#abcd')?.a).toBeCloseTo(0xdd / 255);
+ expect(parseColor(' #000000 ')).toEqual({ r: 0, g: 0, b: 0, a: 1 });
+ });
+
+ it('reads rgb() and rgba() in both syntaxes', () => {
+ expect(parseColor('rgb(59, 130, 246)')).toEqual({ r: 59, g: 130, b: 246, a: 1 });
+ expect(parseColor('rgba(59,130,246,0.5)')).toEqual({ r: 59, g: 130, b: 246, a: 0.5 });
+ expect(parseColor('RGBA(128, 128, 128, .3)')).toEqual({ r: 128, g: 128, b: 128, a: 0.3 });
+ expect(parseColor('rgb(59 130 246 / 50%)')).toEqual({ r: 59, g: 130, b: 246, a: 0.5 });
+ expect(parseColor('rgb(100%, 0%, 0%)')).toEqual({ r: 255, g: 0, b: 0, a: 1 });
+ });
+
+ it('gives null for what it does not understand', () => {
+ expect(parseColor('blue')).toBeNull();
+ expect(parseColor('#12')).toBeNull();
+ expect(parseColor('#12345')).toBeNull();
+ expect(parseColor('rgb(1, 2)')).toBeNull();
+ expect(parseColor('rgb(a, b, c)')).toBeNull();
+ expect(parseColor('hsl(0, 100%, 50%)')).toBeNull();
+ expect(parseColor('')).toBeNull();
+ expect(parseColor(undefined)).toBeNull();
+ });
+});
+
+describe('readableTextOn', () => {
+ it('keeps white while white reaches 3:1 on the fill', () => {
+ expect(readableTextOn('#3b82f6')).toBe('#ffffff'); // blue-500, 3.68:1
+ expect(readableTextOn('#ef4444')).toBe('#ffffff'); // red-500, 3.76:1
+ expect(readableTextOn('#000')).toBe('#ffffff');
+ expect(readableTextOn('rgb(99, 102, 241)')).toBe('#ffffff');
+ });
+
+ it('switches to near-black on light fills', () => {
+ expect(readableTextOn('#22c55e')).toBe(DARK_EVENT_TEXT); // green-500, 2.3:1
+ expect(readableTextOn('#eab308')).toBe(DARK_EVENT_TEXT);
+ expect(readableTextOn('#fbbf24')).toBe(DARK_EVENT_TEXT);
+ expect(readableTextOn('#ffffff')).toBe(DARK_EVENT_TEXT);
+ });
+
+ it('does not depend on the theme', () => {
+ // Same fill, same answer: nothing here reads the palette.
+ expect(readableTextOn('#60a5fa')).toBe(readableTextOn('#60A5FA'));
+ });
+
+ it('falls back to white for colours it cannot read', () => {
+ expect(readableTextOn('tomato')).toBe('#ffffff');
+ });
+});
+
+describe('mixColors', () => {
+ it('mixes like color-mix(in srgb)', () => {
+ expect(mixColors('#ff0000', '#0000ff', 0.55)).toBe('#8c0073');
+ expect(mixColors('#ffffff', '#000000', 0.5)).toBe('#808080');
+ expect(mixColors('#123456', '#abcdef', 1)).toBe('#123456');
+ expect(mixColors('#123456', '#abcdef', 0)).toBe('#abcdef');
+ });
+
+ it('interpolates alpha premultiplied', () => {
+ expect(mixColors('rgba(255, 0, 0, 0.5)', '#0000ff', 0.5)).toBe('rgba(85, 0, 170, 0.75)');
+ expect(mixColors('rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)', 0.5)).toBe('rgba(0, 0, 0, 0)');
+ });
+
+ it('gives null when a colour cannot be read', () => {
+ expect(mixColors('blue', '#000000', 0.5)).toBeNull();
+ expect(mixColors('#000000', 'nope', 0.5)).toBeNull();
+ });
+});
+
+describe('inactiveEventTextColor', () => {
+ it('is the calendar colour mixed 55% with the text colour', () => {
+ // 0.55 × #3b82f6 + 0.45 × #0f172a
+ expect(inactiveEventTextColor('#3b82f6', '#0f172a')).toBe('#27529a');
+ // 0.55 × #3b82f6 + 0.45 × #fafafa
+ expect(inactiveEventTextColor('#3b82f6', '#fafafa')).toBe('#91b8f8');
+ });
+
+ it('falls back to the text colour', () => {
+ expect(inactiveEventTextColor('teal', '#0f172a')).toBe('#0f172a');
+ });
+});
+
+describe('eventBlockColors', () => {
+ const light = { background: '#ffffff', text: '#0f172a' };
+ const dark = { background: '#09090b', text: '#fafafa' };
+
+ it('paints active events solid with the computed label colour in either theme', () => {
+ const expected = { fill: '#3b82f6', text: '#ffffff', border: null };
+ expect(eventBlockColors('#3b82f6', false, light)).toEqual(expected);
+ expect(eventBlockColors('#3b82f6', false, dark)).toEqual(expected);
+ expect(eventBlockColors('#22c55e', false, dark).text).toBe(DARK_EVENT_TEXT);
+ });
+
+ it('puts declined and cancelled events on the page ground inside a calendar-coloured border', () => {
+ expect(eventBlockColors('#3b82f6', true, light)).toEqual({
+ fill: '#ffffff',
+ text: '#27529a',
+ border: '#3b82f6',
+ });
+ expect(eventBlockColors('#3b82f6', true, dark).fill).toBe('#09090b');
+ });
+});
diff --git a/src/lib/calendar-participants.ts b/src/lib/calendar-participants.ts
index ec7842ab..00f132c3 100644
--- a/src/lib/calendar-participants.ts
+++ b/src/lib/calendar-participants.ts
@@ -127,6 +127,30 @@ export function getUserStatus(
return null;
}
+/**
+ * True when the user declined this event. An occurrence carries its override's
+ * participants, so one declined instance of an accepted series counts
+ * (webmail #1110).
+ */
+export function isDeclinedByUser(
+ event: Partial,
+ userEmails: string[] | undefined,
+): boolean {
+ if (!userEmails || userEmails.length === 0) return false;
+ return getUserStatus(event, userEmails) === 'declined';
+}
+
+/**
+ * Declined by the user or cancelled by the organizer: the calendar draws both
+ * the same way, as an outlined, struck-through event (repos/branding/APP.md).
+ */
+export function isInactiveEvent(
+ event: Partial,
+ userEmails: string[] | undefined,
+): boolean {
+ return event.status === 'cancelled' || isDeclinedByUser(event, userEmails);
+}
+
/** When the same address appears in two participant entries, a real RSVP on
* either one beats a missing/"needs-action" one — the duplicate is always the
* entry that never replied. Between two real replies the first-seen entry
diff --git a/src/lib/event-colors.ts b/src/lib/event-colors.ts
new file mode 100644
index 00000000..43553e3a
--- /dev/null
+++ b/src/lib/event-colors.ts
@@ -0,0 +1,172 @@
+// Colours for calendar events, which every view draws as a solid block of
+// the calendar colour (repos/branding/APP.md, "Calendar events"). The label
+// colour is computed from the fill rather than taken from the palette: a
+// fixed "inverse" text colour flips with the theme while the fill does not.
+// React Native has no color-mix(), so the declined/cancelled label colour is
+// mixed here too.
+
+export interface Rgba {
+ /** 0–255 */
+ r: number;
+ g: number;
+ b: number;
+ /** 0–1 */
+ a: number;
+}
+
+/** Label colour on fills too light for white. */
+export const DARK_EVENT_TEXT = '#111827';
+const WHITE = '#ffffff';
+
+/** Share of the calendar colour in a declined or cancelled event's label. */
+export const INACTIVE_TEXT_MIX = 0.55;
+
+const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
+
+function hexByte(hex: string): number {
+ return parseInt(hex.length === 1 ? hex + hex : hex, 16);
+}
+
+// One rgb() channel: 0–255, or a percentage of 255.
+function channel(token: string): number | null {
+ const pct = token.endsWith('%');
+ const n = Number(pct ? token.slice(0, -1) : token);
+ if (!Number.isFinite(n)) return null;
+ return clamp(pct ? (n / 100) * 255 : n, 0, 255);
+}
+
+// The alpha of rgba(): 0–1, or a percentage.
+function alpha(token: string): number | null {
+ const pct = token.endsWith('%');
+ const n = Number(pct ? token.slice(0, -1) : token);
+ if (!Number.isFinite(n)) return null;
+ return clamp(pct ? n / 100 : n, 0, 1);
+}
+
+/**
+ * Parses `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, and `rgb()` / `rgba()` in
+ * both the comma and the space-separated syntax. Anything else (named
+ * colours, hsl()) gives null.
+ */
+export function parseColor(input: string | null | undefined): Rgba | null {
+ if (!input) return null;
+ const s = input.trim().toLowerCase();
+
+ const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(s);
+ if (hex) {
+ const h = hex[1];
+ const short = h.length <= 4;
+ const parts = short ? h.split('') : h.match(/../g) ?? [];
+ const [r, g, b, a] = parts.map(hexByte);
+ return { r, g, b, a: a === undefined ? 1 : a / 255 };
+ }
+
+ const fn = /^rgba?\(([^)]*)\)$/.exec(s);
+ if (fn) {
+ const body = fn[1].trim();
+ let tokens: string[];
+ let alphaToken: string | undefined;
+ if (body.includes(',')) {
+ tokens = body.split(',').map((t) => t.trim());
+ if (tokens.length === 4) alphaToken = tokens.pop();
+ } else {
+ const [rgb, a] = body.split('/').map((t) => t.trim());
+ tokens = rgb ? rgb.split(/\s+/) : [];
+ alphaToken = a;
+ }
+ if (tokens.length !== 3) return null;
+ const [r, g, b] = tokens.map(channel);
+ const a = alphaToken === undefined ? 1 : alpha(alphaToken);
+ if (r === null || g === null || b === null || a === null) return null;
+ return { r, g, b, a };
+ }
+
+ return null;
+}
+
+/** WCAG relative luminance of an sRGB colour (alpha ignored). */
+export function relativeLuminance({ r, g, b }: Rgba): number {
+ const lin = (v: number) => {
+ const c = v / 255;
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
+ };
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
+}
+
+/**
+ * Label colour for text on a solid event fill: white while white keeps 3:1
+ * contrast with the fill, `#111827` on lighter fills. A colour that cannot be
+ * parsed gets white, like the webmail's readableTextOn().
+ */
+export function readableTextOn(fill: string): string {
+ const rgb = parseColor(fill);
+ if (!rgb) return WHITE;
+ const contrastWithWhite = 1.05 / (relativeLuminance(rgb) + 0.05);
+ return contrastWithWhite >= 3 ? WHITE : DARK_EVENT_TEXT;
+}
+
+function toHexByte(n: number): string {
+ return Math.round(clamp(n, 0, 255)).toString(16).padStart(2, '0');
+}
+
+/** `#rrggbb` for an opaque colour, `rgba(…)` otherwise. */
+export function formatColor({ r, g, b, a }: Rgba): string {
+ if (a >= 1) return `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`;
+ const round = (n: number) => Math.round(clamp(n, 0, 255));
+ return `rgba(${round(r)}, ${round(g)}, ${round(b)}, ${Number(a.toFixed(3))})`;
+}
+
+/**
+ * CSS `color-mix(in srgb, a , b)`: `weight` (0–1) of `a`, the rest
+ * of `b`, with premultiplied alpha as CSS does. Null when either colour
+ * cannot be parsed.
+ */
+export function mixColors(a: string, b: string, weight: number): string | null {
+ const ca = parseColor(a);
+ const cb = parseColor(b);
+ if (!ca || !cb) return null;
+ const wa = clamp(weight, 0, 1);
+ const wb = 1 - wa;
+ const outA = ca.a * wa + cb.a * wb;
+ if (outA === 0) return formatColor({ r: 0, g: 0, b: 0, a: 0 });
+ const mix = (x: number, y: number) => (x * ca.a * wa + y * cb.a * wb) / outA;
+ return formatColor({ r: mix(ca.r, cb.r), g: mix(ca.g, cb.g), b: mix(ca.b, cb.b), a: outA });
+}
+
+/**
+ * Label colour of a declined or cancelled event: the calendar colour mixed
+ * 55% with the text colour. Falls back to the text colour when the calendar
+ * colour cannot be parsed.
+ */
+export function inactiveEventTextColor(calendarColor: string, textColor: string): string {
+ return mixColors(calendarColor, textColor, INACTIVE_TEXT_MIX) ?? textColor;
+}
+
+export interface EventBlockColors {
+ /** Block background. */
+ fill: string;
+ /** Title and time. */
+ text: string;
+ /** Inset border in the calendar colour, for declined and cancelled events only. */
+ border: string | null;
+}
+
+/**
+ * Fill, label and border of an event block, bar or chip. Active events are
+ * solid calendar colour with a computed label colour; declined and cancelled
+ * ones sit on the page ground inside a calendar-coloured border.
+ */
+export function eventBlockColors(
+ calendarColor: string,
+ inactive: boolean,
+ page: { background: string; text: string },
+): EventBlockColors {
+ if (inactive) {
+ return {
+ fill: page.background,
+ text: inactiveEventTextColor(calendarColor, page.text),
+ border: calendarColor,
+ };
+ }
+ return { fill: calendarColor, text: readableTextOn(calendarColor), border: null };
+}
diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx
index aca6e297..3b7bb051 100644
--- a/src/screens/CalendarScreen.tsx
+++ b/src/screens/CalendarScreen.tsx
@@ -1050,6 +1050,7 @@ export default function CalendarScreen() {
showWeekNumbers={calendarShowWeekNumbers}
showTimeInMonthView={calendarShowTimeInMonth}
timeFormat={calendarTimeFormat}
+ currentUserEmails={currentUserEmails}
onSelectDate={handleSelectDate}
onLongPressDate={openCreate}
/>
@@ -1065,6 +1066,7 @@ export default function CalendarScreen() {
showWeekNumbers={calendarShowWeekNumbers}
showTimeInMonthView={calendarShowTimeInMonth}
timeFormat={calendarTimeFormat}
+ currentUserEmails={currentUserEmails}
onSelectDate={handleSelectDate}
onLongPressDate={openCreate}
/>
@@ -1084,6 +1086,7 @@ export default function CalendarScreen() {
calendars={calendars}
weekStartsOn={calendarFirstDayOfWeek}
timeFormat={calendarTimeFormat}
+ currentUserEmails={currentUserEmails}
onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent}
onCreateAtTime={openCreate}
@@ -1098,6 +1101,7 @@ export default function CalendarScreen() {
calendars={calendars}
weekStartsOn={calendarFirstDayOfWeek}
timeFormat={calendarTimeFormat}
+ currentUserEmails={currentUserEmails}
onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent}
onCreateAtTime={openCreate}
@@ -1118,6 +1122,7 @@ export default function CalendarScreen() {
eventsByDay={eventsByDay}
calendars={calendars}
timeFormat={calendarTimeFormat}
+ currentUserEmails={currentUserEmails}
onSelectEvent={handleSelectEvent}
/>
)}
@@ -1137,6 +1142,7 @@ export default function CalendarScreen() {
eventsByDay={eventsByDay}
calendars={calendars}
timeFormat={calendarTimeFormat}
+ currentUserEmails={currentUserEmails}
onSelectEvent={handleSelectEvent}
refreshing={refreshing}
onRefresh={onRefresh}
@@ -1261,6 +1267,7 @@ function DayEventList({
eventsByDay,
calendars,
timeFormat,
+ currentUserEmails,
onSelectEvent,
refreshing,
onRefresh,
@@ -1269,6 +1276,7 @@ function DayEventList({
eventsByDay: EventDayIndex;
calendars: Calendar[];
timeFormat?: TimeFormat;
+ currentUserEmails?: string[];
onSelectEvent?: (event: CalendarEvent) => void;
refreshing: boolean;
onRefresh: () => void;
@@ -1307,6 +1315,7 @@ function DayEventList({
event={event}
calendars={calendars}
timeFormat={timeFormat}
+ currentUserEmails={currentUserEmails}
onPress={onSelectEvent}
/>
))}