Skip to content
Merged
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
40 changes: 40 additions & 0 deletions bs3-functions/node-functions/src/archiveExpiredPosts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import { onSchedule } from "firebase-functions/v2/scheduler";

const firestore = admin.firestore();

type Expiry = '24h' | '3d' | '7d';
const EXPIRY_MS: Record<Expiry, number> = {
'24h': 24 * 60 * 60 * 1000,
'3d': 3 * 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000
};

function expiresAt(timestamp: string, expiry?: Expiry): number {
const msAdd = EXPIRY_MS[expiry || '7d'] ?? EXPIRY_MS['7d'];
return new Date(timestamp).getTime() + msAdd;
}

export const archiveExpiredPosts = onSchedule("every 60 minutes", async () => {
const now = Date.now();
const postsRef = firestore.collection("posts");
const archiveRef = firestore.collection("archived_posts");
const snapshot = await postsRef.get();
let archived = 0;

for (const doc of snapshot.docs) {
const data = doc.data();
const expiry: Expiry | undefined = data.expiryPeriod;
const timestamp: string = data.timestamp;
if (!timestamp) continue; // skip malformed docs
const expiryTime = expiresAt(timestamp, expiry);
if (now >= expiryTime) {
// Copy then delete
await archiveRef.doc(doc.id).set(data);
await postsRef.doc(doc.id).delete();
archived++;
}
}
functions.logger.info(`Archived ${archived} posts at ${new Date().toISOString()}`);
});
1 change: 1 addition & 0 deletions bs3-functions/node-functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import * as admin from "firebase-admin";
import { Perplexity, Headlines } from "./api";
import { onSchedule } from "firebase-functions/v2/scheduler";
import { logger } from "firebase-functions";
export { archiveExpiredPosts } from "./archiveExpiredPosts";

// For cost control, you can set the maximum number of containers that can be
// running at the same time. This helps mitigate the impact of unexpected
Expand Down
4 changes: 3 additions & 1 deletion bs3/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,6 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*

*.firebase
*.firebase

.env
19 changes: 16 additions & 3 deletions bs3/src/components/post-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import { PostData } from '../types/types';
import CommentsSection from './comments-section';
import { timeAgo } from './utils';
import { timeAgo, timeRemaining } from './utils';
import { collection, onSnapshot, query } from 'firebase/firestore';
import { db } from '../api/firebaseConfig'; // adjust import if needed

Expand Down Expand Up @@ -176,16 +176,29 @@ export default function BasicCard({ post, extended = false }: BasicCardProps) {
</Modal>
</div>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2" color="text.secondary" gutterBottom>
{commentsCount}
</Typography>
<ChatBubbleIcon fontSize="small" sx={{color: 'gray'}}/>
<ChatBubbleIcon fontSize="small" sx={{ color: 'gray' }} />
<Typography variant="body2" color="text.secondary" gutterBottom>
{timeAgo(post.timestamp)}
</Typography>
</Stack>
{/* Post expiry display */}
{(() => {
const { text, isExpiringSoon } = timeRemaining(post.timestamp, post.expiryPeriod);
return (
<Typography
variant="body2"
sx={{ ml: 2, color: isExpiringSoon ? 'red' : 'text.secondary', fontWeight: isExpiringSoon ? 700 : 500 }}
title={text}
>
{text}
</Typography>
);
})()}
</Box>
<Stack
direction="row"
Expand Down
40 changes: 39 additions & 1 deletion bs3/src/components/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,42 @@ export function timeAgo(timestamp: string): string {
const days = Math.floor(diffInSeconds / 86400);
return `${days} day${days > 1 ? 's' : ''} ago`;
}
}
}

// Calculates remaining time given a start timestamp and expiryPeriod
export function timeRemaining(timestamp: string, expiryPeriod?: '24h' | '3d' | '7d'): { text: string; isExpiringSoon: boolean } {
const now = new Date();
const start = new Date(timestamp);
let msToAdd = 0;
switch (expiryPeriod) {
case '24h':
msToAdd = 24 * 60 * 60 * 1000;
break;
case '3d':
msToAdd = 3 * 24 * 60 * 60 * 1000;
break;
case '7d':
default:
msToAdd = 7 * 24 * 60 * 60 * 1000;
break;
}
const expiresAt = new Date(start.getTime() + msToAdd);
const diff = expiresAt.getTime() - now.getTime();
if (diff <= 0) return { text: 'Expired', isExpiringSoon: false };

const seconds = Math.floor(diff / 1000) % 60;
const minutes = Math.floor(diff / (1000 * 60)) % 60;
const hours = Math.floor(diff / (1000 * 60 * 60)) % 24;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));

let parts = [];
if (days > 0) parts.push(`${days} day${days !== 1 ? 's' : ''}`);
if (hours > 0) parts.push(`${hours} hour${hours !== 1 ? 's' : ''}`);
if (days === 0 && minutes > 0) parts.push(`${minutes} min${minutes !== 1 ? 's' : ''}`);
if (days === 0 && hours === 0 && minutes === 0) parts.push(`${seconds} sec${seconds !== 1 ? 's' : ''}`);

// Highlight if less than 6h
const isExpiringSoon = (diff <= 6 * 60 * 60 * 1000);

return { text: 'Expires in ' + parts.join(' '), isExpiringSoon };
}
25 changes: 24 additions & 1 deletion bs3/src/pages/new-post-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,13 @@ interface FormValues {
body: string;
upVoted: string[];
downVoted: string[];
expiryPeriod: '24h' | '3d' | '7d';
}

const validationSchema = yup.object({
tag: yup
.string()
.matches(/^\S*$/, "Tag must not contain spaces")
.matches(/^[^\s]*$/, "Tag must not contain spaces")
.matches(/^[a-zA-Z0-9]+$/, "Tag must contain only letters and numbers")
.max(16, "Tag must be 16 characters or less"),
author: yup.string().default("Anonymous"),
Expand All @@ -54,6 +55,9 @@ const validationSchema = yup.object({
.string()
.min(32, "Body must be 32 characters long.")
.required("Body is required"),
expiryPeriod: yup.mixed<'24h' | '3d' | '7d'>()
.oneOf(['24h', '3d', '7d'])
.default('7d')
});

export default function PostForm(): JSX.Element {
Expand All @@ -75,6 +79,7 @@ export default function PostForm(): JSX.Element {
body: "",
upVoted: [],
downVoted: [],
expiryPeriod: '7d',
},
validationSchema,
onSubmit: (values: FormValues) => {
Expand All @@ -87,6 +92,7 @@ export default function PostForm(): JSX.Element {
...values,
userId,
timestamp: new Date().toISOString(),
expiryPeriod: values.expiryPeriod || '7d',
};
if (!newPost.author) {
newPost.author = "anonymous";
Expand Down Expand Up @@ -171,6 +177,23 @@ export default function PostForm(): JSX.Element {
),
}}
/>
<TextField
select
id="expiryPeriod"
name="expiryPeriod"
label="Expires In"
value={formik.values.expiryPeriod}
onChange={formik.handleChange}
SelectProps={{ native: true }}
sx={{ mt: 2 }}
>
<option value="24h">24 hours</option>
<option value="3d">3 days</option>
<option value="7d">7 days</option>
</TextField>
<FormHelperText>
Duration for which the post stays visible
</FormHelperText>
<TextField
id="author"
name="author"
Expand Down
1 change: 1 addition & 0 deletions bs3/src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type PostData = {
downVoted: string[];
timestamp: string;
userId: string;
expiryPeriod?: '24h' | '3d' | '7d'; // Optional for migration/backward compatibility
};

export type FirestoreState = {
Expand Down