From 657da77814304814745a3450785efe65e86b6f46 Mon Sep 17 00:00:00 2001 From: Roman Popat Date: Fri, 23 Jan 2026 14:37:37 +0000 Subject: [PATCH 1/2] expiring posts UI --- bs3/.gitignore | 4 +++- bs3/src/components/post-card.tsx | 19 ++++++++++++--- bs3/src/components/utils.ts | 40 +++++++++++++++++++++++++++++++- bs3/src/pages/new-post-form.tsx | 25 +++++++++++++++++++- bs3/src/types/types.ts | 1 + 5 files changed, 83 insertions(+), 6 deletions(-) diff --git a/bs3/.gitignore b/bs3/.gitignore index a8d984f..b41dc11 100644 --- a/bs3/.gitignore +++ b/bs3/.gitignore @@ -22,4 +22,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -*.firebase \ No newline at end of file +*.firebase + +.env diff --git a/bs3/src/components/post-card.tsx b/bs3/src/components/post-card.tsx index 377d82a..23f8f69 100644 --- a/bs3/src/components/post-card.tsx +++ b/bs3/src/components/post-card.tsx @@ -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 @@ -176,16 +176,29 @@ export default function BasicCard({ post, extended = false }: BasicCardProps) { )} - + {commentsCount} - + {timeAgo(post.timestamp)} + {/* Post expiry display */} + {(() => { + const { text, isExpiringSoon } = timeRemaining(post.timestamp, post.expiryPeriod); + return ( + + {text} + + ); + })()} 1 ? 's' : ''} ago`; } - } \ No newline at end of file +} + +// 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 }; +} diff --git a/bs3/src/pages/new-post-form.tsx b/bs3/src/pages/new-post-form.tsx index 977c8a2..71c34fd 100644 --- a/bs3/src/pages/new-post-form.tsx +++ b/bs3/src/pages/new-post-form.tsx @@ -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"), @@ -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 { @@ -75,6 +79,7 @@ export default function PostForm(): JSX.Element { body: "", upVoted: [], downVoted: [], + expiryPeriod: '7d', }, validationSchema, onSubmit: (values: FormValues) => { @@ -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"; @@ -171,6 +177,23 @@ export default function PostForm(): JSX.Element { ), }} /> + + + + + + + Duration for which the post stays visible + Date: Fri, 23 Jan 2026 14:37:51 +0000 Subject: [PATCH 2/2] post expiry backend --- .../node-functions/src/archiveExpiredPosts.ts | 40 +++++++++++++++++++ bs3-functions/node-functions/src/index.ts | 1 + 2 files changed, 41 insertions(+) create mode 100644 bs3-functions/node-functions/src/archiveExpiredPosts.ts diff --git a/bs3-functions/node-functions/src/archiveExpiredPosts.ts b/bs3-functions/node-functions/src/archiveExpiredPosts.ts new file mode 100644 index 0000000..33fa81c --- /dev/null +++ b/bs3-functions/node-functions/src/archiveExpiredPosts.ts @@ -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 = { + '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()}`); +}); diff --git a/bs3-functions/node-functions/src/index.ts b/bs3-functions/node-functions/src/index.ts index 98cc6d3..9047ac2 100644 --- a/bs3-functions/node-functions/src/index.ts +++ b/bs3-functions/node-functions/src/index.ts @@ -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