Skip to content

Timeline

@granit/timeline provides framework-agnostic types and API functions for an entity activity stream — mirroring Granit.Timeline on the .NET backend. It supports three entry types (comments, internal notes, system logs), threaded replies, file attachments via blob IDs, and @mention syntax.

@granit/react-timeline wraps these into a provider and hooks with infinite scroll, optimistic updates, and follower management.

Peer dependencies: axios, @granit/logger, react ^19, @tanstack/react-query ^5

  • Directory@granit/timeline/ Entry types, stream/follower API functions (framework-agnostic)
    • @granit/react-timeline TimelineProvider, stream/actions/followers hooks
PackageRoleDepends on
@granit/timelineDTOs, entry type enum, stream/follower API functionsaxios
@granit/react-timelineTimelineProvider, useTimeline, useTimelineActions, useTimelineFollowers@granit/timeline, @granit/react-querying, @granit/logger, react
import { TimelineProvider } from '@granit/react-timeline';
import { api } from './api-client';
function EntityDetail({ children }) {
return (
<TimelineProvider apiClient={api} basePath="/api/v1/timeline">
{children}
</TimelineProvider>
);
}
const TimelineEntryType = {
Comment: 0, // Visible to all users
InternalNote: 1, // Restricted visibility (internal staff)
SystemLog: 2, // Automatic system-generated entries
} as const;
type TimelineEntryTypeValue = (typeof TimelineEntryType)[keyof typeof TimelineEntryType];
interface TimelineStreamEntry {
readonly id: string;
readonly entityType: string;
readonly entityId: string;
readonly entryType: TimelineEntryTypeValue;
readonly body: string;
readonly authorId: string;
readonly authorDisplayName: string;
readonly parentEntryId: string | null; // null = top-level, set = threaded reply
readonly createdAt: string;
readonly attachmentBlobIds: string[];
}
interface TimelineStreamPage {
readonly items: TimelineStreamEntry[];
readonly totalCount: number;
readonly nextCursor: string | null;
}
interface CreateTimelineEntryRequest {
readonly entryType: TimelineEntryTypeValue;
readonly body: string;
readonly parentEntryId?: string;
readonly attachmentBlobIds?: string[];
}
interface TimelineQueryParams {
readonly page?: number;
readonly pageSize?: number; // default: 20
}
interface MentionSuggestion {
readonly id: string;
readonly displayName: string;
}
FunctionEndpointDescription
fetchStream(client, basePath, entityType, entityId, params?)GET /{entityType}/{entityId}Paginated timeline stream
createEntry(client, basePath, entityType, entityId, request)POST /{entityType}/{entityId}/entriesCreate comment, note, or log
deleteEntry(client, basePath, entityType, entityId, entryId)DELETE /{entityType}/{entityId}/entries/{id}Delete an entry
followEntity(client, basePath, entityType, entityId)POST /{entityType}/{entityId}/followFollow entity
unfollowEntity(client, basePath, entityType, entityId)DELETE /{entityType}/{entityId}/followUnfollow entity
fetchFollowers(client, basePath, entityType, entityId)GET /{entityType}/{entityId}/followersList follower user IDs

Mentions use the format @[Display Name](user:guid). The server extracts mentioned user IDs from this pattern to trigger notifications.

interface TimelineProviderProps {
apiClient: AxiosInstance;
basePath?: string; // default: '/api/v1/timeline'
children: React.ReactNode;
}
<TimelineProvider apiClient={api}>
{children}
</TimelineProvider>

Infinite-scroll timeline stream with optimistic update helpers.

interface UseTimelineReturn {
readonly entries: readonly TimelineStreamEntry[];
readonly totalCount: number;
readonly loading: boolean;
readonly loadingMore: boolean;
readonly error: Error | null;
readonly hasMore: boolean;
readonly loadMore: () => void;
readonly refresh: () => void;
readonly addOptimisticEntry: (entry: TimelineStreamEntry) => void;
readonly removeOptimisticEntry: (entryId: string) => void;
}

Create and delete timeline entries with callbacks for optimistic updates.

interface UseTimelineActionsOptions {
entityType: string;
entityId: string;
onEntryCreated?: (entry: TimelineStreamEntry) => void;
onEntryDeleted?: (entryId: string) => void;
}
interface UseTimelineActionsReturn {
readonly postEntry: (request: CreateTimelineEntryRequest) => Promise<TimelineStreamEntry>;
readonly removeEntry: (entryId: string) => Promise<void>;
readonly posting: boolean;
readonly deleting: boolean;
readonly error: Error | null;
}

Follower management with follow/unfollow toggle.

interface UseTimelineFollowersOptions {
entityType: string;
entityId: string;
currentUserId?: string;
}
interface UseTimelineFollowersReturn {
readonly followers: string[];
readonly isFollowing: boolean;
readonly loading: boolean;
readonly error: Error | null;
readonly follow: () => Promise<void>;
readonly unfollow: () => Promise<void>;
}

Wire useTimelineActions callbacks into useTimeline for instant UI feedback:

const timeline = useTimeline({ entityType: 'Invoice', entityId: id });
const actions = useTimelineActions({
entityType: 'Invoice',
entityId: id,
onEntryCreated: (entry) => timeline.addOptimisticEntry(entry),
onEntryDeleted: (entryId) => timeline.removeOptimisticEntry(entryId),
});

Replies are entries with parentEntryId set to the parent entry’s ID. The rendering and indentation is a client-side concern — the hooks return a flat list that can be grouped by parentEntryId.

CategoryKey exportsPackage
EnumsTimelineEntryType@granit/timeline
TypesTimelineStreamEntry, CreateTimelineEntryRequest, TimelineStreamPage, MentionSuggestion@granit/timeline
Stream APIfetchStream(), createEntry(), deleteEntry()@granit/timeline
Follower APIfollowEntity(), unfollowEntity(), fetchFollowers()@granit/timeline
ProviderTimelineProvider, useTimelineConfig()@granit/react-timeline
Stream hooksuseTimeline(), useTimelineActions()@granit/react-timeline
Follower hookuseTimelineFollowers()@granit/react-timeline