[READ ONLY MIRROR] Spark Social AppView Server
github.com/sprksocial/server
atproto
deno
hono
lexicon
1import { Database } from "../db/index.ts";
2import { TimeCidKeyset } from "../db/pagination.ts";
3import { compositeTime } from "../util.ts";
4
5const STORIES_EXPIRY_HOURS = 24;
6
7export interface StoryItem {
8 uri: string;
9 cid: string;
10 authorDid: string;
11 createdAt: string;
12 indexedAt: string;
13 sortAt: string;
14 archived: boolean;
15}
16
17export class Stories {
18 private db: Database;
19 private timeCidKeyset: TimeCidKeyset;
20
21 constructor(db: Database) {
22 this.db = db;
23 this.timeCidKeyset = new TimeCidKeyset();
24 }
25
26 /**
27 * Get active (non-expired) stories by URIs
28 */
29 async getStories(uris: string[]): Promise<StoryItem[]> {
30 if (!uris.length) return [];
31
32 const twentyFourHoursAgo = new Date();
33 twentyFourHoursAgo.setHours(
34 twentyFourHoursAgo.getHours() - STORIES_EXPIRY_HOURS,
35 );
36 const minDate = twentyFourHoursAgo.toISOString();
37
38 const stories = await this.db.models.Story.find({
39 uri: { $in: uris },
40 indexedAt: { $gte: minDate },
41 }).lean();
42
43 return stories.map((story) => ({
44 uri: story.uri,
45 cid: story.cid,
46 authorDid: story.authorDid,
47 createdAt: story.createdAt,
48 indexedAt: story.indexedAt,
49 archived: false,
50 sortAt: compositeTime(story.createdAt, story.indexedAt) ||
51 story.createdAt,
52 }));
53 }
54
55 /**
56 * Get timeline stories from followed users (including the viewer's own stories)
57 */
58 async getTimeline(
59 actorDid: string,
60 followedDids: string[],
61 limit = 50,
62 cursor?: string,
63 ): Promise<{ stories: StoryItem[]; cursor?: string }> {
64 const timelineDids = [...followedDids, actorDid];
65
66 if (timelineDids.length === 0) {
67 return { stories: [] };
68 }
69
70 // Calculate 24-hour expiry threshold
71 const twentyFourHoursAgo = new Date();
72 twentyFourHoursAgo.setHours(
73 twentyFourHoursAgo.getHours() - STORIES_EXPIRY_HOURS,
74 );
75 const minDate = twentyFourHoursAgo.toISOString();
76
77 // Build query with expiry filter
78 const storiesQuery = this.db.models.Story.find({
79 authorDid: { $in: timelineDids },
80 indexedAt: { $gte: minDate },
81 });
82
83 // Apply pagination
84 const paginatedQuery = this.timeCidKeyset.paginate(storiesQuery, {
85 limit: limit + 1, // Get one extra for cursor check
86 cursor,
87 direction: "desc",
88 });
89
90 const stories = await paginatedQuery.exec();
91
92 // Check if we have more results
93 const hasMore = stories.length > limit;
94 const resultStories = hasMore ? stories.slice(0, limit) : stories;
95
96 // Transform stories
97 const transformedStories: StoryItem[] = resultStories.map((story) => ({
98 uri: story.uri,
99 cid: story.cid,
100 authorDid: story.authorDid,
101 createdAt: story.createdAt,
102 indexedAt: story.indexedAt,
103 archived: false,
104 sortAt: compositeTime(story.createdAt, story.indexedAt) ||
105 story.createdAt,
106 }));
107
108 // Generate cursor from last item if we have more results
109 let nextCursor: string | undefined;
110 if (hasMore && resultStories.length > 0) {
111 nextCursor = this.timeCidKeyset.packFromResult(resultStories);
112 }
113
114 return {
115 stories: transformedStories,
116 cursor: nextCursor,
117 };
118 }
119
120 /**
121 * Filter out expired stories (older than 24 hours)
122 */
123 filterExpiredStories(
124 stories: StoryItem[],
125 ): StoryItem[] {
126 const twentyFourHoursAgo = new Date();
127 twentyFourHoursAgo.setHours(
128 twentyFourHoursAgo.getHours() - STORIES_EXPIRY_HOURS,
129 );
130
131 return stories.filter((story) => {
132 const storyDate = new Date(story.indexedAt);
133 return storyDate >= twentyFourHoursAgo;
134 });
135 }
136
137 /**
138 * Get active stories grouped by actor DID
139 */
140 async getActorStories(
141 dids: string[],
142 ): Promise<Map<string, { uri: string; cid: string }[]>> {
143 if (!dids.length) return new Map();
144
145 const twentyFourHoursAgo = new Date();
146 twentyFourHoursAgo.setHours(
147 twentyFourHoursAgo.getHours() - STORIES_EXPIRY_HOURS,
148 );
149 const minDate = twentyFourHoursAgo.toISOString();
150
151 const stories = await this.db.models.Story.find({
152 authorDid: { $in: dids },
153 indexedAt: { $gte: minDate },
154 }).sort({ indexedAt: 1 }).lean();
155
156 const result = new Map<string, { uri: string; cid: string }[]>();
157 for (const story of stories) {
158 const existing = result.get(story.authorDid) ?? [];
159 existing.push({ uri: story.uri, cid: story.cid });
160 result.set(story.authorDid, existing);
161 }
162 return result;
163 }
164
165 /**
166 * Get blocked author DIDs for a viewer
167 */
168 async getBlockedAuthors(
169 viewerDid: string,
170 authorDids: string[],
171 ): Promise<Set<string>> {
172 if (authorDids.length === 0) {
173 return new Set();
174 }
175
176 // Single query to get all block relationships
177 const [viewerBlocking, viewerBlocked] = await Promise.all([
178 this.db.models.Block.find({
179 authorDid: viewerDid,
180 subject: { $in: authorDids },
181 }).select("subject").lean(),
182 this.db.models.Block.find({
183 authorDid: { $in: authorDids },
184 subject: viewerDid,
185 }).select("authorDid").lean(),
186 ]);
187
188 const blockedAuthorDids = new Set([
189 ...viewerBlocking.map((b) => b.subject),
190 ...viewerBlocked.map((b) => b.authorDid),
191 ]);
192
193 return blockedAuthorDids;
194 }
195}