Skip to content

Commit

Permalink
Merge pull request #279 from jaredh159/merge-stray-keystroke-lines
Browse files Browse the repository at this point in the history
  • Loading branch information
jaredh159 authored Dec 20, 2023
2 parents a794b5c + e5a131f commit d969d44
Show file tree
Hide file tree
Showing 6 changed files with 391 additions and 101 deletions.
1 change: 1 addition & 0 deletions dash/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"uuid": "9.0.0"
},
"devDependencies": {
"@shared/datetime": "workspace:*",
"@types/uuid": "8.3.4"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Button } from '@shared/components';
import { posessive } from '@shared/string';
import type { ActivityFeedItem } from './UserActivityFeed';
import { FeedCaughtUp } from './UserActivityFeed';
import { deleteableChunks } from './UserActivityFeed';
import DeletableActivityChunks from './DeletableActivityChunks';
import FeedHeader from './FeedHeader';
import ReviewDayWrapper from './ReviewDayWrapper';
import UserActivityHeader from './UserActivityHeader';
Expand Down Expand Up @@ -57,7 +57,11 @@ const CombinedUsersActivityFeed: React.FC<Props> = ({
className="flex flex-col justify-center space-y-4 md:border md:border-slate-200 md:rounded-3xl md:pt-6 lg:pt-8 md:px-4 lg:px-8 md:pb-0 md:bg-white/50"
>
<UserActivityHeader>{userName}</UserActivityHeader>
{deleteableChunks(items, chunkSize, deleteItems)}
<DeletableActivityChunks
items={items}
chunkSize={chunkSize}
deleteItems={deleteItems}
/>
{items.length > 1 && (
<div className="flex justify-center pb-8">
<Button
Expand Down
190 changes: 190 additions & 0 deletions dash/components/src/Users/Activity/DeletableActivityChunks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import React from 'react';
import { Button } from '@shared/components';
import type { ActivityFeedItem } from './UserActivityFeed';
import KeystrokesViewer from './KeystrokesViewer';
import ScreenshotViewer from './ScreenshotViewer';

const DeletableActivityChunks: React.FC<{
items: ActivityFeedItem[];
chunkSize: number;
deleteItems: (ids: UUID[]) => unknown;
}> = ({ items, deleteItems, chunkSize }) => (
<>
{chunkedRenderTasks(items, chunkSize)
.flat(1)
.map((item) => {
switch (item.type) {
case `item`:
return (
<Item
key={item.item.id}
item={item.item}
deleteItem={() => deleteItems([item.item.id])}
/>
);
case `suspension_group`:
return (
<div
key={`${item.items[0]?.id ?? ``}-suspension-group`}
className="ml-2 md:-ml-6 mt-4 pl-4 md:pl-5 rounded-l-3xl border-4 border-r-0 border-red-500/60"
>
<div className="bg-slate-100 md:bg-slate-50 -mt-4 pl-3 font-medium text-lg text-red-600">
During filter suspension
</div>
<div className="flex flex-col gap-8 pt-2 pb-4">
{item.items.map((item) => (
<Item
key={item.id}
item={item}
deleteItem={() => deleteItems([item.id])}
/>
))}
</div>
<div className="bg-slate-100 md:bg-slate-50 h-2 -mb-1 ml-8"></div>
</div>
);
default: // @link https://github.com/typescript-eslint/typescript-eslint/issues/2841
return (
<div
key={`${item.ids[item.ids.length - 1] ?? ``}-delete-btn`}
className="flex justify-center pb-8"
>
<Button
type="button"
color="secondary-on-violet-bg"
className="ScrollTop"
onClick={() => deleteItems(item.ids)}
>
Approve previous {item.ids.length} items
</Button>
</div>
);
}
})}
</>
);

export default DeletableActivityChunks;

const Item: React.FC<{
item: ActivityFeedItem;
deleteItem: () => unknown;
}> = ({ item, deleteItem }) => {
if (item.type === `Screenshot`) {
return (
<ScreenshotViewer
url={item.url}
width={item.width}
height={item.height}
onApprove={deleteItem}
date={new Date(item.date)}
duringSuspension={item.duringSuspension}
/>
);
} else {
return (
<KeystrokesViewer
strokes={item.line}
application={item.appName}
onApprove={deleteItem}
duringSuspension={item.duringSuspension}
date={new Date(item.date)}
/>
);
}
};

export type Chunkable = {
id: UUID;
type: 'Screenshot' | 'KeystrokeLine';
duringSuspension: boolean;
date: ISODateString;
};

type ActivityRenderTask<T extends Chunkable> =
| { type: 'item'; item: T }
| { type: 'suspension_group'; items: T[] }
| { type: 'delete_btn'; ids: UUID[] };

// an extraction of core logic without rendering for testability
export function chunkedRenderTasks<T extends Chunkable>(
items: T[],
chunkSize: number,
): Array<ActivityRenderTask<T>[]> {
const ids: UUID[] = [];
const chunkedTasks: Array<ActivityRenderTask<T>[]> = [];
const numChunks = Math.ceil(items.length / chunkSize);

for (let chunkIndex = 0; chunkIndex < numChunks; chunkIndex++) {
const tasks: ActivityRenderTask<T>[] = [];
const chunkOffset = chunkIndex * chunkSize;
const chunkItems = items.slice(chunkOffset, chunkOffset + chunkSize);
let suspensionBuffer: T[] = [];

for (let i = 0; i < chunkItems.length; i++) {
const item = chunkItems[i];
if (!item) continue;
const isLastItem = chunkItems[chunkItems.length - 1]?.id === item.id;
const finishingSuspension =
(!item.duringSuspension && suspensionBuffer.length > 0) ||
(isLastItem && item.duringSuspension);

if (item.duringSuspension) {
suspensionBuffer.push(item);
}

if (finishingSuspension) {
if (shouldBeMergedIntoSuspensionGroup(item, chunkItems, i)) {
item.duringSuspension = true;
suspensionBuffer.push(item);
} else {
tasks.push({ type: `suspension_group`, items: suspensionBuffer });
suspensionBuffer = [];
}
}

if (!item.duringSuspension) {
tasks.push({ type: `item`, item });
}

ids.push(item.id);
}

if (chunkIndex < numChunks - 1) {
const toDelete = [...ids];
tasks.push({ type: `delete_btn`, ids: toDelete });
}
chunkedTasks.push(tasks);
}

return chunkedTasks;
}

// because of the way the macapp emits activity events
// we sometimes get one or two keystroke lines near the
// end of a suspension period marked as not during suspension.
// this helps fold those into a suspension group so we don't
// end up with one or two un-suspended keystroke lines
// in the middle of a bunch of suspended ones
function shouldBeMergedIntoSuspensionGroup(
item: Chunkable,
items: Chunkable[],
index: number,
): boolean {
if (item.type === `Screenshot`) return false;
const prev = items[index - 1];
if (!prev) return false;
const prevDate = new Date(prev.date);
// if we can peek ahead a few and find a suspended item within 4 minutes
// while only skipping over keystroke lines, then we should merge
for (const peek of items.slice(index + 1, index + 5)) {
if (!peek.duringSuspension) continue;
if (peek.type === `KeystrokeLine`) return false;
const peekDate = new Date(peek.date);
const diff = prevDate.getTime() - peekDate.getTime();
if (diff < 1000 * 60 * 4) {
return true;
}
}
return false;
}
105 changes: 6 additions & 99 deletions dash/components/src/Users/Activity/UserActivityFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ import { useNavigate } from 'react-router-dom';
import { Button } from '@shared/components';
import { UndoMainPadding } from '../../Chrome/Chrome';
import EmptyState from '../../EmptyState';
import KeystrokesViewer from './KeystrokesViewer';
import ScreenshotViewer from './ScreenshotViewer';
import FeedHeader from './FeedHeader';
import ReviewDayWrapper from './ReviewDayWrapper';
import DeletableActivityChunks from './DeletableActivityChunks';

interface Screenshot {
type: 'Screenshot';
Expand Down Expand Up @@ -51,7 +50,11 @@ const UserActivityFeed: React.FC<Props> = ({
<FeedHeader date={date} numItems={items.length} numDeleted={numDeleted} />
{items.length > 0 ? (
<ReviewDayWrapper>
{deleteableChunks(items, chunkSize, deleteItems)}
<DeletableActivityChunks
items={items}
chunkSize={chunkSize}
deleteItems={deleteItems}
/>
<Button
className="ScrollTop self-center"
type="button"
Expand Down Expand Up @@ -86,99 +89,3 @@ export const FeedCaughtUp: React.FC = () => (
className="max-w-7xl"
/>
);

export function deleteableChunks(
items: ActivityFeedItem[],
chunkSize: number,
deleteItems: (ids: UUID[]) => unknown,
): JSX.Element[] {
const ids: UUID[] = [];
const elements: JSX.Element[] = [];
const numChunks = Math.ceil(items.length / chunkSize);

for (let chunkIndex = 0; chunkIndex < numChunks; chunkIndex++) {
const chunkOffset = chunkIndex * chunkSize;
const chunkItems = items.slice(chunkOffset, chunkOffset + chunkSize);

let bufferingSuspensionItems = false;
let buffer: JSX.Element[] = [];

for (const item of chunkItems) {
const isLastItem = chunkItems.indexOf(item) === chunkItems.length - 1;
const finishingSuspension =
(!item.duringSuspension && bufferingSuspensionItems) ||
(isLastItem && item.duringSuspension);
bufferingSuspensionItems = item.duringSuspension;

if (item.duringSuspension) {
buffer.push(renderItem(item, () => deleteItems([item.id])));
}
if (finishingSuspension) {
elements.push(
<div
key={buffer[0]?.key}
className="ml-2 md:-ml-6 mt-4 pl-4 md:pl-5 rounded-l-3xl border-4 border-r-0 border-red-500/60"
>
<div className="bg-slate-100 md:bg-slate-50 -mt-4 pl-3 font-medium text-lg text-red-600">
During filter suspension
</div>
<div className="flex flex-col gap-8 pt-2 pb-4">{buffer}</div>
<div className="bg-slate-100 md:bg-slate-50 h-2 -mb-1 ml-8"></div>
</div>,
);
buffer = [];
}
if (!item.duringSuspension) {
elements.push(renderItem(item, () => deleteItems([item.id])));
}
ids.push(item.id);
}

if (chunkIndex < numChunks - 1) {
const toDelete = [...ids];
elements.push(
<div
key={`${ids[ids.length - 1] ?? ``}-separator`}
className="flex justify-center pb-8"
>
<Button
type="button"
color="secondary-on-violet-bg"
className="ScrollTop"
onClick={() => deleteItems(toDelete)}
>
Approve previous {[...ids].length} items
</Button>
</div>,
);
}
}
return elements;
}

function renderItem(item: ActivityFeedItem, deleteItem: () => unknown): JSX.Element {
if (item.type === `Screenshot`) {
return (
<ScreenshotViewer
key={item.id}
url={item.url}
width={item.width}
height={item.height}
onApprove={deleteItem}
date={new Date(item.date)}
duringSuspension={item.duringSuspension}
/>
);
} else {
return (
<KeystrokesViewer
key={item.id}
strokes={item.line}
application={item.appName}
onApprove={deleteItem}
duringSuspension={item.duringSuspension}
date={new Date(item.date)}
/>
);
}
}
Loading

0 comments on commit d969d44

Please sign in to comment.