Skip to content

Commit

Permalink
feat: implement activity stream (#5485)
Browse files Browse the repository at this point in the history
* wip: implement activity stream

* feat: add empty stream

* fix: filter empty topic ids

* fix: update progress group

* fix: update icon

* feat: add topic titles

* fix: update topic title

* fix: update http call

* Redesign activity stream

* Add activity stream

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
  • Loading branch information
arikchakma and kamranahmedse committed Apr 22, 2024
1 parent 4db353e commit dccbe68
Show file tree
Hide file tree
Showing 11 changed files with 553 additions and 172 deletions.
20 changes: 16 additions & 4 deletions src/components/Activity/ActivityPage.tsx
Expand Up @@ -4,6 +4,7 @@ import { ActivityCounters } from './ActivityCounters';
import { ResourceProgress } from './ResourceProgress';
import { pageProgressMessage } from '../../stores/page';
import { EmptyActivity } from './EmptyActivity';
import { ActivityStream, type UserStreamActivity } from './ActivityStream';

type ProgressResponse = {
updatedAt: string;
Expand Down Expand Up @@ -45,6 +46,7 @@ export type ActivityResponse = {
resourceTitle?: string;
};
}[];
activities: UserStreamActivity[];
};

export function ActivityPage() {
Expand Down Expand Up @@ -96,8 +98,13 @@ export function ActivityPage() {

return updatedAtB.getTime() - updatedAtA.getTime();
})
.filter((bestPractice) => bestPractice.learning > 0 || bestPractice.done > 0);
.filter(
(bestPractice) => bestPractice.learning > 0 || bestPractice.done > 0,
);

const hasProgress =
learningRoadmapsToShow.length !== 0 ||
learningBestPracticesToShow.length !== 0;

return (
<>
Expand All @@ -107,16 +114,17 @@ export function ActivityPage() {
streak={activity?.streak || { count: 0 }}
/>

<div className="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
<div className="mx-0 px-0 py-5 pb-0 md:-mx-10 md:px-8 md:py-8 md:pb-0">
{learningRoadmapsToShow.length === 0 &&
learningBestPracticesToShow.length === 0 && <EmptyActivity />}

{(learningRoadmapsToShow.length > 0 || learningBestPracticesToShow.length > 0) && (
{(learningRoadmapsToShow.length > 0 ||
learningBestPracticesToShow.length > 0) && (
<>
<h2 className="mb-3 text-xs uppercase text-gray-400">
Continue Following
</h2>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{learningRoadmaps
.sort((a, b) => {
const updatedAtA = new Date(a.updatedAt);
Expand Down Expand Up @@ -192,6 +200,10 @@ export function ActivityPage() {
</>
)}
</div>

{hasProgress && (
<ActivityStream activities={activity?.activities || []} />
)}
</>
);
}
163 changes: 163 additions & 0 deletions src/components/Activity/ActivityStream.tsx
@@ -0,0 +1,163 @@
import { useMemo, useState } from 'react';
import { getRelativeTimeString } from '../../lib/date';
import type { ResourceType } from '../../lib/resource-progress';
import { EmptyStream } from './EmptyStream';
import { ActivityTopicsModal } from './ActivityTopicsModal.tsx';
import {Book, BookOpen, ChevronsDown, ChevronsDownUp, ChevronsUp, ChevronsUpDown} from 'lucide-react';

export const allowedActivityActionType = [
'in_progress',
'done',
'answered',
] as const;
export type AllowedActivityActionType =
(typeof allowedActivityActionType)[number];

export type UserStreamActivity = {
_id?: string;
resourceType: ResourceType | 'question';
resourceId: string;
resourceTitle: string;
resourceSlug?: string;
isCustomResource?: boolean;
actionType: AllowedActivityActionType;
topicIds?: string[];
createdAt: Date;
updatedAt: Date;
};

type ActivityStreamProps = {
activities: UserStreamActivity[];
};

export function ActivityStream(props: ActivityStreamProps) {
const { activities } = props;

const [showAll, setShowAll] = useState(false);
const [selectedActivity, setSelectedActivity] =
useState<UserStreamActivity | null>(null);

const sortedActivities = activities
.filter((activity) => activity?.topicIds && activity.topicIds.length > 0)
.sort((a, b) => {
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
})
.slice(0, showAll ? activities.length : 10);

return (
<div className="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
<h2 className="mb-3 text-xs uppercase text-gray-400">
Learning Activity
</h2>

{selectedActivity && (
<ActivityTopicsModal
onClose={() => setSelectedActivity(null)}
activityId={selectedActivity._id!}
resourceId={selectedActivity.resourceId}
resourceType={selectedActivity.resourceType}
isCustomResource={selectedActivity.isCustomResource}
topicIds={selectedActivity.topicIds || []}
topicCount={selectedActivity.topicIds?.length || 0}
actionType={selectedActivity.actionType}
/>
)}

{activities.length > 0 ? (
<ul className="divide-y divide-gray-100">
{sortedActivities.map((activity) => {
const {
_id,
resourceType,
resourceId,
resourceTitle,
actionType,
updatedAt,
topicIds,
isCustomResource,
} = activity;

const resourceUrl =
resourceType === 'question'
? `/questions/${resourceId}`
: resourceType === 'best-practice'
? `/best-practices/${resourceId}`
: isCustomResource && resourceType === 'roadmap'
? `/r/${resourceId}`
: `/${resourceId}`;

const resourceLinkComponent = (
<a
className="font-medium underline transition-colors hover:cursor-pointer hover:text-black"
target="_blank"
href={resourceUrl}
>
{resourceTitle}
</a>
);

const topicCount = topicIds?.length || 0;

const timeAgo = (
<span className="ml-1 text-xs text-gray-400">
{getRelativeTimeString(new Date(updatedAt).toISOString())}
</span>
);

return (
<li key={_id} className="py-2 text-sm text-gray-600">
{actionType === 'in_progress' && (
<>
Started{' '}
<button
className="font-medium underline underline-offset-2 hover:text-black"
onClick={() => setSelectedActivity(activity)}
>
{topicCount} topic{topicCount > 1 ? 's' : ''}
</button>{' '}
in {resourceLinkComponent} {timeAgo}
</>
)}
{actionType === 'done' && (
<>
Completed{' '}
<button
className="font-medium underline underline-offset-2 hover:text-black"
onClick={() => setSelectedActivity(activity)}
>
{topicCount} topic{topicCount > 1 ? 's' : ''}
</button>{' '}
in {resourceLinkComponent} {timeAgo}
</>
)}
{actionType === 'answered' && (
<>
Answered {topicCount} question{topicCount > 1 ? 's' : ''} in{' '}
{resourceLinkComponent} {timeAgo}
</>
)}
</li>
);
})}
</ul>
) : (
<EmptyStream />
)}

{activities.length > 10 && (
<button
className="mt-3 gap-2 flex items-center rounded-md border border-black pl-1.5 pr-2 py-1 text-xs uppercase tracking-wide text-black transition-colors hover:border-black hover:bg-black hover:text-white"
onClick={() => setShowAll(!showAll)}
>
{showAll ? <>
<ChevronsUp size={14} />
Show less
</> : <>
<ChevronsDown size={14} />
Show all
</>}
</button>
)}
</div>
);
}
136 changes: 136 additions & 0 deletions src/components/Activity/ActivityTopicsModal.tsx
@@ -0,0 +1,136 @@
import { useEffect, useState } from 'react';
import type { ResourceType } from '../../lib/resource-progress';
import type { AllowedActivityActionType } from './ActivityStream';
import { httpPost } from '../../lib/http';
import { Modal } from '../Modal.tsx';
import { ModalLoader } from '../UserProgress/ModalLoader.tsx';
import { ArrowUpRight, BookOpen, Check } from 'lucide-react';

type ActivityTopicDetailsProps = {
activityId: string;
resourceId: string;
resourceType: ResourceType | 'question';
isCustomResource?: boolean;
topicIds: string[];
topicCount: number;
actionType: AllowedActivityActionType;
onClose: () => void;
};

export function ActivityTopicsModal(props: ActivityTopicDetailsProps) {
const {
resourceId,
resourceType,
isCustomResource,
topicIds = [],
topicCount,
actionType,
onClose,
} = props;

const [isLoading, setIsLoading] = useState(true);
const [topicTitles, setTopicTitles] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);

const loadTopicTitles = async () => {
setIsLoading(true);
setError(null);

const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-get-topic-titles`,
{
resourceId,
resourceType,
isCustomResource,
topicIds,
},
);

if (error || !response) {
setError(error?.message || 'Failed to load topic titles');
setIsLoading(false);
return;
}

setTopicTitles(response);
setIsLoading(false);
};

useEffect(() => {
loadTopicTitles().finally(() => {
setIsLoading(false);
});
}, []);

if (isLoading || error) {
return (
<ModalLoader
error={error!}
text={'Loading topics..'}
isLoading={isLoading}
/>
);
}

let pageUrl = '';
if (resourceType === 'roadmap') {
pageUrl = isCustomResource ? `/r/${resourceId}` : `/${resourceId}`;
} else if (resourceType === 'best-practice') {
pageUrl = `/best-practices/${resourceId}`;
} else {
pageUrl = `/questions/${resourceId}`;
}

return (
<Modal
onClose={() => {
onClose();
setError(null);
setIsLoading(false);
}}
>
<div className={`popup-body relative rounded-lg bg-white p-4 shadow`}>
<span className="mb-2 flex items-center justify-between text-lg font-semibold capitalize">
<span className="flex items-center gap-2">
{actionType.replace('_', ' ')}
</span>
<a
href={pageUrl}
target="_blank"
className="flex items-center gap-1 rounded-md border border-transparent py-0.5 pl-2 pr-1 text-sm font-normal text-gray-400 transition-colors hover:border-black hover:bg-black hover:text-white"
>
Visit Page{' '}
<ArrowUpRight
size={16}
strokeWidth={2}
className="relative top-px"
/>
</a>
</span>
<ul className="flex flex-col gap-1">
{topicIds.map((topicId) => {
const topicTitle = topicTitles[topicId] || 'Unknown Topic';

const ActivityIcon =
actionType === 'done'
? Check
: actionType === 'in_progress'
? BookOpen
: Check;

return (
<li key={topicId} className="flex items-start gap-2">
<ActivityIcon
strokeWidth={3}
className="relative top-[4px] text-green-500"
size={16}
/>
{topicTitle}
</li>
);
})}
</ul>
</div>
</Modal>
);
}
31 changes: 31 additions & 0 deletions src/components/Activity/EmptyStream.tsx
@@ -0,0 +1,31 @@
import { List } from 'lucide-react';

export function EmptyStream() {
return (
<div className="rounded-md">
<div className="flex flex-col items-center p-7 text-center">
<List className="mb-2 h-[60px] w-[60px] opacity-10 sm:h-[120px] sm:w-[120px]" />

<h2 className="text-lg font-bold sm:text-xl">No Activities</h2>
<p className="my-1 max-w-[400px] text-balance text-sm text-gray-500 sm:my-2 sm:text-base">
Activities will appear here as you start tracking your&nbsp;
<a href="/roadmaps" className="mt-4 text-blue-500 hover:underline">
Roadmaps
</a>
,&nbsp;
<a
href="/best-practices"
className="mt-4 text-blue-500 hover:underline"
>
Best Practices
</a>
&nbsp;or&nbsp;
<a href="/questions" className="mt-4 text-blue-500 hover:underline">
Questions
</a>
&nbsp;progress.
</p>
</div>
</div>
);
}

0 comments on commit dccbe68

Please sign in to comment.