Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,14 @@ export default function Tokens() {
label: "Spawn Sample 10",
onPress: () => devMenu.spawnSampleTokens(10),
},
{
androidIcon: CodeSymbol,
disabled: devMenu.tokenActionDisabled,
iosIcon: "list.bullet.rectangle.fill",
key: "tokens-spawn-activity",
label: "Spawn Activity Log",
onPress: devMenu.spawnAllActivityTypes,
},
{
androidIcon: DeleteSymbol,
destructive: true,
Expand Down
19 changes: 19 additions & 0 deletions src/components/notification-handler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
handlePushAuthRequest,
} from "@/services/push-auth";
import { usePushRequestStore } from "@/stores/push-request";
import { useActivityStore } from "@/stores/activity";
import { ActivityType } from "@/types/activity";
import type { PushRequest } from "@/types/push-request";
import { PushRequestStatus } from "@/types/push-request";
import { useCallback } from "react";
Expand All @@ -17,6 +19,7 @@ import { PushRequestPopup } from "./push-request-popup";
export function NotificationHandler() {
const { tokens } = useToken();
const { pushRequests, updatePushRequestStatus } = usePushRequestStore();
const addActivity = useActivityStore((state) => state.addActivity);

const handleRequest = useCallback(
async (request: PushRequest): Promise<boolean> => {
Expand All @@ -39,13 +42,29 @@ export function NotificationHandler() {
if (action === "ACCEPT") {
console.log("User accepted the push authentication");
updatePushRequestStatus(pushRequest.id, PushRequestStatus.Accepted);
addActivity({
id: `push-approved-${pushRequest.id}`,
tokenId: pushRequest.serial,
type: ActivityType.PushApproved,
timestamp: Date.now(),
pushRequestId: pushRequest.id,
title: pushRequest.title || undefined,
});
void handleRequest({
...pushRequest,
status: PushRequestStatus.Accepted,
});
} else if (action === "DECLINE") {
console.log("User declined the push authentication");
updatePushRequestStatus(pushRequest.id, PushRequestStatus.Declined);
addActivity({
id: `push-denied-${pushRequest.id}`,
tokenId: pushRequest.serial,
type: ActivityType.PushDenied,
timestamp: Date.now(),
pushRequestId: pushRequest.id,
title: pushRequest.title || undefined,
});
void handleRequest({
...pushRequest,
status: PushRequestStatus.Declined,
Expand Down
22 changes: 21 additions & 1 deletion src/components/push-request-popup.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Radii, Spacing, Typography } from "@/constants/theme";
import { useTheme } from "@/hooks/use-theme";
import { usePushRequestStore } from "@/stores/push-request";
import { useActivityStore } from "@/stores/activity";
import { useTokenStore } from "@/stores/token";
import { ActivityType } from "@/types/activity";
import type { PushRequest } from "@/types/push-request";
import { PushRequestStatus } from "@/types/push-request";
import { playImpactLightHaptic, playImpactMediumHaptic } from "@/utils/haptics";
Expand Down Expand Up @@ -44,6 +46,7 @@ export function PushRequestPopup({
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
const colorScheme = useColorScheme();
const { updatePushRequestStatus } = usePushRequestStore();
const addActivity = useActivityStore((state) => state.addActivity);
const pendingRequests = requests.filter(
(request) => request.status === PushRequestStatus.Pending,
);
Expand All @@ -68,11 +71,28 @@ export function PushRequestPopup({
setTimeout(() => {
const updatedRequest = { ...currentRequest, status };
updatePushRequestStatus(updatedRequest.id, status);
addActivity({
id: `push-${action === "accept" ? "approved" : "denied"}-${updatedRequest.id}`,
tokenId: updatedRequest.serial,
type:
action === "accept"
? ActivityType.PushApproved
: ActivityType.PushDenied,
timestamp: Date.now(),
pushRequestId: updatedRequest.id,
title: updatedRequest.title || undefined,
});
onAction(updatedRequest);
setIsAnimatingOut(false);
}, 200);
},
[currentRequest, isAnimatingOut, onAction, updatePushRequestStatus],
[
addActivity,
currentRequest,
isAnimatingOut,
onAction,
updatePushRequestStatus,
],
);

if (!currentRequest) return null;
Expand Down
218 changes: 217 additions & 1 deletion src/components/token-detail/token-overview-content.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { StatusCard } from "@/components/status-card";
import { ThemedPressable } from "@/components/themed-pressable";
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import { TokenImage } from "@/components/token-image";
import { Radii, Spacing, Typography } from "@/constants/theme";
import { useTheme } from "@/hooks/use-theme";
import { useActivityStore } from "@/stores/activity";
import { ActivityType, type Activity } from "@/types/activity";
import {
PushTokenRefreshStatus,
PushTokenRolloutState,
Expand All @@ -13,7 +16,8 @@ import ForwardMediaSymbol from "@expo/material-symbols/forward_media.xml";
import { Button, Host, Icon, Row, Text } from "@expo/ui";
import { buttonStyle, controlSize } from "@expo/ui/swift-ui/modifiers";
import { useLingui } from "@lingui/react/macro";
import { StyleSheet, View } from "react-native";
import { useCallback, useMemo, useState } from "react";
import { Alert, StyleSheet, View, type ColorValue } from "react-native";
import {
formatTimestamp,
getRolloutFailureDetails,
Expand All @@ -22,6 +26,8 @@ import {
refreshErrorMessages,
} from "./token-detail-utils";

const ACTIVITY_PAGE_SIZE = 20;

function DetailRow({ label, value }: { label: string; value: string }) {
return (
<View style={styles.detailRow}>
Expand All @@ -39,6 +45,65 @@ function DetailRow({ label, value }: { label: string; value: string }) {
);
}

function ActivityRow({ activity }: { activity: Activity }) {
const { t } = useLingui();
const theme = useTheme();
const content = {
[ActivityType.EnrollmentStarted]: {
label: t`Enrollment started`,
color: theme.branding,
},
[ActivityType.EnrollmentCompleted]: {
label: t`Enrollment completed`,
color: theme.successBar,
},
[ActivityType.EnrollmentFailed]: {
label: t`Enrollment failed`,
color: theme.error,
},
[ActivityType.PushReceived]: {
label: t`Push request received`,
color: theme.branding,
},
[ActivityType.PushApproved]: {
label: t`Push request approved`,
color: theme.successBar,
},
[ActivityType.PushDenied]: {
label: t`Push request denied`,
color: theme.error,
},
} satisfies Record<ActivityType, { label: string; color: ColorValue }>;
const event = content[activity.type];
const timestamp = new Date(activity.timestamp).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});

return (
<View style={styles.activityRow}>
<View style={[styles.activityDot, { backgroundColor: event.color }]} />
<View style={styles.activityContent}>
<ThemedText fontSize={Typography.fontSize14} fontWeight="semiBold">
{event.label}
</ThemedText>
{activity.title ? (
<ThemedText
themeColor="textSecondary"
fontSize={Typography.fontSize12}
numberOfLines={1}
>
{activity.title}
</ThemedText>
) : null}
<ThemedText themeColor="textSecondary" fontSize={Typography.fontSize12}>
{timestamp}
</ThemedText>
</View>
</View>
);
}

export function TokenOverviewContent({
token,
onRetryRollout,
Expand All @@ -48,6 +113,18 @@ export function TokenOverviewContent({
}) {
const { i18n, t } = useLingui();
const theme = useTheme();
const allActivities = useActivityStore((state) => state.activities);
const clearTokenActivities = useActivityStore(
(state) => state.clearTokenActivities,
);
const [visibleActivityCount, setVisibleActivityCount] =
useState(ACTIVITY_PAGE_SIZE);
const activities = useMemo(
() => allActivities.filter((activity) => activity.tokenId === token.id),
[allActivities, token.id],
);
const visibleActivities = activities.slice(0, visibleActivityCount);
const hasOlderActivities = visibleActivities.length < activities.length;
const isRolloutFailed = PushTokenRolloutState.isFailed(token.rolloutState);
const rolloutFailureDetails = getRolloutFailureDetails(token.rolloutState);
const rolloutStateLabel = getRolloutStateLabel(token.rolloutState);
Expand All @@ -63,6 +140,23 @@ export function TokenOverviewContent({
networkMessage: i18n._(refreshErrorMessages.networkMessage),
},
);
const confirmClearActivityLog = useCallback(() => {
Alert.alert(
t`Clear activity log?`,
t`This permanently removes all activity for this token.`,
[
{ text: t`Cancel`, style: "cancel" },
{
text: t`Clear`,
style: "destructive",
onPress: () => {
clearTokenActivities(token.id);
setVisibleActivityCount(ACTIVITY_PAGE_SIZE);
},
},
],
);
}, [clearTokenActivities, t, token.id]);

return (
<>
Expand Down Expand Up @@ -169,11 +263,126 @@ export function TokenOverviewContent({
<DetailRow label={t`Serial`} value={token.id} />
</ThemedView>
</View>

<View style={styles.section}>
<View style={styles.activityHeader}>
<ThemedText fontSize={Typography.fontSize18} fontWeight="bold">
{t`Activity Log`}
</ThemedText>
{activities.length > 0 ? (
<ThemedPressable
accessibilityRole="button"
onPress={confirmClearActivityLog}
style={({ pressed }) => [
styles.clearActivityButton,
{ opacity: pressed ? 0.65 : 1 },
]}
type="errorBackground"
>
<ThemedText
fontSize={Typography.fontSize12}
fontWeight="semiBold"
themeColor="error"
>
{t`Clear`}
</ThemedText>
</ThemedPressable>
) : null}
</View>
<ThemedView type="backgroundSecondary" style={styles.activityCard}>
{activities.length > 0 ? (
<>
{visibleActivities.map((activity) => (
<ActivityRow key={activity.id} activity={activity} />
))}
{hasOlderActivities ? (
<ThemedPressable
accessibilityRole="button"
onPress={() =>
setVisibleActivityCount((count) =>
Math.min(count + ACTIVITY_PAGE_SIZE, activities.length),
)
}
style={({ pressed }) => [
styles.showOlderButton,
{ opacity: pressed ? 0.65 : 1 },
]}
type="fill"
>
<ThemedText
fontSize={Typography.fontSize14}
fontWeight="semiBold"
>
{t`Show older activity`}
</ThemedText>
</ThemedPressable>
) : null}
</>
) : (
<View style={styles.auditPlaceholder}>
<ThemedText
fontSize={Typography.fontSize16}
fontWeight="semiBold"
>
{t`No interactions yet`}
</ThemedText>
<ThemedText
themeColor="textSecondary"
fontSize={Typography.fontSize14}
style={styles.auditDescription}
>
{t`Token approvals, denials, push requests, and enrollment activity will appear here.`}
</ThemedText>
</View>
)}
</ThemedView>
</View>
</>
);
}

const styles = StyleSheet.create({
activityCard: {
borderRadius: Radii.xl,
paddingHorizontal: Spacing.lg,
},
activityContent: {
flex: 1,
gap: Spacing.xxs,
},
activityDot: {
borderRadius: 5,
height: 10,
marginTop: Spacing.xs,
width: 10,
},
activityHeader: {
alignItems: "center",
flexDirection: "row",
justifyContent: "space-between",
},
activityRow: {
flexDirection: "row",
gap: Spacing.md,
paddingVertical: Spacing.md,
},
auditDescription: {
lineHeight: 20,
marginTop: Spacing.xs,
textAlign: "center",
},
auditPlaceholder: {
alignItems: "center",
justifyContent: "center",
minHeight: 132,
padding: Spacing.xl,
},
clearActivityButton: {
alignItems: "center",
borderRadius: Radii.pill,
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
},
detailRow: {
gap: Spacing.xs,
paddingVertical: Spacing.md,
Expand Down Expand Up @@ -216,6 +425,13 @@ const styles = StyleSheet.create({
serverErrorMessage: {
lineHeight: 20,
},
showOlderButton: {
alignItems: "center",
borderRadius: Radii.lg,
marginBottom: Spacing.lg,
marginTop: Spacing.sm,
padding: Spacing.md,
},
statusMeta: {
marginTop: Spacing.md,
},
Expand Down
Loading
Loading