From 85a9e26f39e139881b89c460b890cbcdf8bdc46e Mon Sep 17 00:00:00 2001 From: Kimin Kim <55262612+kiminkim724@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:56:47 -0400 Subject: [PATCH 1/6] Add script to migrate emails from profiles to users collection (#2054) --- .../firebase-admin/migrateProfileEmails.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 scripts/firebase-admin/migrateProfileEmails.ts diff --git a/scripts/firebase-admin/migrateProfileEmails.ts b/scripts/firebase-admin/migrateProfileEmails.ts new file mode 100644 index 000000000..2d393b2f1 --- /dev/null +++ b/scripts/firebase-admin/migrateProfileEmails.ts @@ -0,0 +1,47 @@ +import { FieldValue } from "functions/src/firebase" +import { Script } from "./types" + +// Migrate emails from profiles to users collection and delete from profiles +export const script: Script = async ({ db, auth }) => { + const profilesSnapshot = await db + .collection("profiles") + .where("email", ">", "") + .get() + let emailUpdateCount = 0 + + console.log(`Migrating emails for ${profilesSnapshot.size} profiles`) + + const bulkWriter = db.bulkWriter() + + for (const profileDoc of profilesSnapshot.docs) { + const profileData = profileDoc.data() + const userId = profileDoc.id + const email = profileData.email + + if (email) { + const userRef = db.collection("users").doc(userId) + const userDoc = await userRef.get() + const userEmail = userDoc.exists ? userDoc.data()?.email : undefined + + if (userEmail) { + if (userEmail !== email) { + console.error( + `Email mismatch for userId ${userId}: profile email "${email}", user email "${userEmail}"` + ) + } else { + // Email is the same, just delete from profile collection + bulkWriter.update(profileDoc.ref, { email: FieldValue.delete() }) + } + } else { + // No email in user collection, set it + bulkWriter.set(userRef, { email }, { merge: true }) + bulkWriter.update(profileDoc.ref, { email: FieldValue.delete() }) + emailUpdateCount++ + } + } + } + + await bulkWriter.close() + + console.log(`Updated emails for ${emailUpdateCount} users`) +} From 6278b4c4217bbc3c7507bf994ecf49a6d77cac55 Mon Sep 17 00:00:00 2001 From: Janice Lichtman Date: Tue, 24 Mar 2026 20:15:30 -0400 Subject: [PATCH 2/6] Phone verification setup (#2058) * Phone verified added to firestore data model, and cloud function added to set it * Added front end hooks for cloud completePhoneVerification function * added get verified button on edit profile page * added empty Verify your phone number modal * Have working flow for phone verification from edit profile page * Added handling for account-exists-with-different-credential error * fixed formatting * Added a feature flag to hide phone verification UI changes behind --- .../EditProfilePage/EditProfileHeader.tsx | 27 +- .../EditProfilePage/EditProfilePage.tsx | 9 + .../PhoneVerificationModal.tsx | 234 ++++++++++++++++++ components/auth/hooks.ts | 29 ++- components/auth/types.tsx | 5 + components/db/profile/types.ts | 1 + components/featureFlags.ts | 13 +- firestore.rules | 6 +- functions/src/index.ts | 2 +- .../src/profile/completePhoneVerification.ts | 25 ++ functions/src/profile/index.ts | 1 + functions/src/profile/types.ts | 3 +- public/images/verifiedUser.png | Bin 0 -> 1151 bytes public/locales/en/editProfile.json | 20 ++ 14 files changed, 363 insertions(+), 12 deletions(-) create mode 100644 components/EditProfilePage/PhoneVerificationModal.tsx create mode 100644 functions/src/profile/completePhoneVerification.ts create mode 100644 public/images/verifiedUser.png diff --git a/components/EditProfilePage/EditProfileHeader.tsx b/components/EditProfilePage/EditProfileHeader.tsx index b5c02f5b7..8f0b1a111 100644 --- a/components/EditProfilePage/EditProfileHeader.tsx +++ b/components/EditProfilePage/EditProfileHeader.tsx @@ -3,19 +3,25 @@ import { Role } from "../auth" import { Col, Row } from "../bootstrap" import { GearIcon, OutlineButton } from "../buttons" import { ProfileEditToggle } from "components/ProfilePage/ProfileButtons" +import { useFlags } from "components/featureFlags" export const EditProfileHeader = ({ formUpdated, onSettingsModalOpen, + onGetVerifiedClick, uid, - role + role, + phoneVerified }: { formUpdated: boolean onSettingsModalOpen: () => void + onGetVerifiedClick?: () => void uid: string role: Role + phoneVerified?: boolean }) => { const { t } = useTranslation("editProfile") + const { phoneVerificationUI } = useFlags() return ( @@ -30,6 +36,25 @@ export const EditProfileHeader = ({ onClick={() => onSettingsModalOpen()} /> + {phoneVerificationUI && + (phoneVerified === true ? ( +
+ {t("verifiedUser")} + {t("verifiedUserBadgeAlt")} +
+ ) : onGetVerifiedClick ? ( + + ) : null)}
) diff --git a/components/EditProfilePage/EditProfilePage.tsx b/components/EditProfilePage/EditProfilePage.tsx index ad7eb6e0f..2fff9d96a 100644 --- a/components/EditProfilePage/EditProfilePage.tsx +++ b/components/EditProfilePage/EditProfilePage.tsx @@ -17,6 +17,7 @@ import { import { EditProfileHeader } from "./EditProfileHeader" import { FollowingTab } from "./FollowingTab" import { PersonalInfoTab } from "./PersonalInfoTab" +import PhoneVerificationModal from "./PhoneVerificationModal" import ProfileSettingsModal from "./ProfileSettingsModal" import { StyledTabContent, @@ -87,6 +88,8 @@ export function EditProfileForm({ const [formUpdated, setFormUpdated] = useState(false) const [settingsModal, setSettingsModal] = useState<"show" | null>(null) + const [showPhoneVerificationModal, setShowPhoneVerificationModal] = + useState(false) const [notifications, setNotifications] = useState( notificationFrequency || "Weekly" ) @@ -178,8 +181,10 @@ export function EditProfileForm({ setShowPhoneVerificationModal(true)} uid={uid} role={profile.role} + phoneVerified={profile.phoneVerified} /> setSettingsModal(null)} show={settingsModal === "show"} /> + setShowPhoneVerificationModal(false)} + /> ) } diff --git a/components/EditProfilePage/PhoneVerificationModal.tsx b/components/EditProfilePage/PhoneVerificationModal.tsx new file mode 100644 index 000000000..718dbc299 --- /dev/null +++ b/components/EditProfilePage/PhoneVerificationModal.tsx @@ -0,0 +1,234 @@ +import { + type ConfirmationResult, + linkWithPhoneNumber, + RecaptchaVerifier +} from "firebase/auth" +import { useEffect, useRef, useState } from "react" +import type { ModalProps } from "react-bootstrap" +import { Alert, Col, Form, Modal } from "../bootstrap" +import { LoadingButton } from "../buttons" +import Input from "../forms/Input" +import { useAuth } from "../auth" +import { getErrorMessage } from "../auth/hooks" +import { useCompletePhoneVerification } from "../auth/hooks" +import { auth } from "../firebase" +import { useTranslation } from "next-i18next" + +const US_REGEX = + /^(\([2-9][0-9]{2}\)|[2-9][0-9]{2})[- ]?([0-9]{3})[- ]?([0-9]{4})$/ + +const AUTH_ERROR_CODE_TO_KEY: Record = { + "auth/credential-already-in-use": + "phoneVerification.errors.credentialAlreadyInUse", + "auth/account-exists-with-different-credential": + "phoneVerification.errors.credentialAlreadyInUse", + "auth/provider-already-linked": + "phoneVerification.errors.providerAlreadyLinked", + "auth/invalid-phone-number": "phoneVerification.errors.invalidPhoneNumber", + "auth/operation-not-allowed": "phoneVerification.errors.operationNotAllowed" +} + +export default function PhoneVerificationModal({ + show, + onHide +}: Pick) { + const { t } = useTranslation("editProfile") + const { user } = useAuth() + const completePhoneVerification = useCompletePhoneVerification() + + const [step, setStep] = useState<"phone" | "code">("phone") + const [phone, setPhone] = useState("") + const [code, setCode] = useState("") + const [error, setError] = useState(null) + const [sendingCode, setSendingCode] = useState(false) + const [verifying, setVerifying] = useState(false) + const [confirmationResult, setConfirmationResult] = + useState(null) + const recaptchaVerifierRef = useRef(null) + const phoneInputRef = useRef(null) + const codeInputRef = useRef(null) + const RECAPTCHA_CONTAINER_ID = "phone-verification-recaptcha-container" + + const getModalErrorMessage = (code: string | undefined) => { + if (!code) return getErrorMessage(code) + const key = AUTH_ERROR_CODE_TO_KEY[code] + return key ? t(key) : getErrorMessage(code) + } + + useEffect(() => { + if (!show) { + setStep("phone") + setPhone("") + setCode("") + setError(null) + setConfirmationResult(null) + setSendingCode(false) + setVerifying(false) + if (recaptchaVerifierRef.current) { + try { + recaptchaVerifierRef.current.clear() + } catch { + // ignore if already cleared + } + recaptchaVerifierRef.current = null + } + completePhoneVerification.reset() + } + // could not add a reference to completePhoneVerification.reset to dep array without triggering an infinite effect, so: + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [show]) + + const handleSendCode = async () => { + setError(null) + const trimmed = phone.trim() + if (!US_REGEX.test(trimmed)) { + setError(getModalErrorMessage("auth/invalid-phone-number")) + return + } + const phoneDigits = trimmed.replace(/\D/g, "") + const firebasePhoneFormat = `+1${phoneDigits}` + + if (!user) { + setError(t("phoneVerification.signedInRequired")) + return + } + + setSendingCode(true) + try { + if (!recaptchaVerifierRef.current) { + recaptchaVerifierRef.current = new RecaptchaVerifier( + RECAPTCHA_CONTAINER_ID, + { size: "invisible" }, + auth + ) + } + const result = await linkWithPhoneNumber( + user, + firebasePhoneFormat, + recaptchaVerifierRef.current + ) + setConfirmationResult(result) + setStep("code") + } catch (err: unknown) { + const code = (err as { code?: string })?.code + setError( + getModalErrorMessage(code) || + (err as Error)?.message || + getErrorMessage() + ) + } finally { + setSendingCode(false) + } + } + + const handleVerify = async () => { + setError(null) + if (!confirmationResult || !code.trim()) { + setError(t("phoneVerification.enterVerificationCode")) + return + } + setVerifying(true) + try { + await confirmationResult.confirm(code.trim()) + if (completePhoneVerification.execute) { + await completePhoneVerification.execute() + } + onHide?.() + } catch (err: unknown) { + const code = (err as { code?: string })?.code + setError( + getModalErrorMessage(code) || + (err as Error)?.message || + getErrorMessage() + ) + } finally { + setVerifying(false) + } + } + + useEffect(() => { + if (!show) return + const el = step === "phone" ? phoneInputRef.current : codeInputRef.current + if (el) { + const id = requestAnimationFrame(() => el.focus()) + return () => cancelAnimationFrame(id) + } + }, [show, step]) + + return ( + + + + {t("phoneVerificationModalTitle")} + + + + + {error ? ( + setError(null)}> + {error} + + ) : null} + + {step === "phone" ? ( +
{ + e.preventDefault() + handleSendCode() + }} + > + setPhone(e.target.value)} + className="mb-3" + /> +
+ + {t("phoneVerification.continue")} + + + ) : ( +
{ + e.preventDefault() + handleVerify() + }} + > + setCode(e.target.value)} + className="mb-3" + /> + + {t("phoneVerification.verify")} + +
+ )} + + + + ) +} diff --git a/components/auth/hooks.ts b/components/auth/hooks.ts index bfdb49415..a618d6460 100644 --- a/components/auth/hooks.ts +++ b/components/auth/hooks.ts @@ -12,17 +12,29 @@ import { import { useAsyncCallback } from "react-async-hook" import { setProfile } from "../db" import { auth } from "../firebase" -import { finishSignup, OrgCategory } from "./types" +import { completePhoneVerification, finishSignup, OrgCategory } from "./types" const errorMessages: Record = { "auth/email-already-exists": "You already have an account.", "auth/email-already-in-use": "You already have an account.", "auth/wrong-password": "Your password is wrong.", "auth/invalid-email": "The email you provided is not a valid email.", - "auth/user-not-found": "You don't have an account." + "auth/user-not-found": "You don't have an account.", + "functions/failed-precondition": + "Phone number is not linked to this account. Complete phone verification first.", + "auth/credential-already-in-use": + "This phone number is already linked to another account.", + "auth/account-exists-with-different-credential": + "This phone number is already linked to another account.", + "auth/provider-already-linked": + "This account already has a phone number linked.", + "auth/invalid-phone-number": + "Please enter a valid phone number (e.g. 617 555-1234).", + "auth/operation-not-allowed": + "Phone verification is not enabled. Please try again later or contact us at info@mapletestimony.org." } -function getErrorMessage(errorCode?: string) { +export function getErrorMessage(errorCode?: string) { const niceErrorMessage = errorCode ? errorMessages[errorCode] : undefined return niceErrorMessage || "Something went wrong!" } @@ -39,7 +51,9 @@ function useFirebaseFunction( console.log(err) const message = getErrorMessage( - err instanceof FirebaseError ? err.code : undefined + err instanceof FirebaseError + ? err.code + : (err as { code?: string })?.code ) throw new Error(message) } @@ -104,6 +118,13 @@ export function useSendEmailVerification() { return useFirebaseFunction((user: User) => sendEmailVerification(user)) } +/** Call after the user has linked a phone number via linkWithPhoneNumber + confirm. */ +export function useCompletePhoneVerification() { + return useFirebaseFunction( + async () => (await completePhoneVerification()).data + ) +} + export type SendPasswordResetEmailData = { email: string } export function useSendPasswordResetEmail() { diff --git a/components/auth/types.tsx b/components/auth/types.tsx index c3170e281..ec1b49309 100644 --- a/components/auth/types.tsx +++ b/components/auth/types.tsx @@ -9,3 +9,8 @@ export const finishSignup = httpsCallable< { requestedRole: Role } | Partial, void >(functions, "finishSignup") + +export const completePhoneVerification = httpsCallable< + void, + { phoneVerified: true } +>(functions, "completePhoneVerification") diff --git a/components/db/profile/types.ts b/components/db/profile/types.ts index b99b80d5d..898b84f2e 100644 --- a/components/db/profile/types.ts +++ b/components/db/profile/types.ts @@ -43,4 +43,5 @@ export type Profile = { contactInfo?: ContactInfo location?: string orgCategories?: OrgCategory[] | "" + phoneVerified?: boolean } diff --git a/components/featureFlags.ts b/components/featureFlags.ts index 11f3b438f..5e080a850 100644 --- a/components/featureFlags.ts +++ b/components/featureFlags.ts @@ -15,7 +15,9 @@ export const FeatureFlags = z.object({ /** LLM Bill Summary and Tags **/ showLLMFeatures: z.boolean().default(false), /** Hearings and Transcriptions **/ - hearingsAndTranscriptions: z.boolean().default(false) + hearingsAndTranscriptions: z.boolean().default(false), + /** Phone Verification UI changes **/ + phoneVerificationUI: z.boolean().default(false) }) export type FeatureFlags = z.infer @@ -35,7 +37,8 @@ const defaults: Record = { followOrg: true, lobbyingTable: false, showLLMFeatures: true, - hearingsAndTranscriptions: true + hearingsAndTranscriptions: true, + phoneVerificationUI: true }, production: { testimonyDiffing: false, @@ -44,7 +47,8 @@ const defaults: Record = { followOrg: true, lobbyingTable: false, showLLMFeatures: true, - hearingsAndTranscriptions: true + hearingsAndTranscriptions: true, + phoneVerificationUI: false }, test: { testimonyDiffing: false, @@ -53,7 +57,8 @@ const defaults: Record = { followOrg: true, lobbyingTable: false, showLLMFeatures: true, - hearingsAndTranscriptions: true + hearingsAndTranscriptions: true, + phoneVerificationUI: true } } diff --git a/firestore.rules b/firestore.rules index 5cbca817b..978809c0f 100644 --- a/firestore.rules +++ b/firestore.rules @@ -34,6 +34,10 @@ service cloud.firestore { // email digest notification times return !request.resource.data.diff(resource.data).affectedKeys().hasAny(['nextDigestAt']) } + function doesNotChangePhoneVerified() { + // Only the completePhoneVerification cloud function (Admin SDK) sets phoneVerified + return !request.resource.data.diff(resource.data).affectedKeys().hasAny(['phoneVerified']) + } // either the change doesn't include the public field, // or the user is a base user (i.e. not an org) function validPublicChange() { @@ -52,7 +56,7 @@ service cloud.firestore { // Allow users to make updates except to delete their profile or set the role field. // Only admins can delete a user profile or set the user role field. - allow update: if validUser() && doesNotChangeRole() && validPublicChange() && doesNotChangeNextDigestAt() + allow update: if validUser() && doesNotChangeRole() && validPublicChange() && doesNotChangeNextDigestAt() && doesNotChangePhoneVerified() } // Allow querying publications individually or with a collection group. match /{path=**}/publishedTestimony/{id} { diff --git a/functions/src/index.ts b/functions/src/index.ts index d3effd4be..b396108d9 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -31,7 +31,7 @@ export { fetchMemberBatch, startMemberBatches } from "./members" -export { finishSignup } from "./profile" +export { completePhoneVerification, finishSignup } from "./profile" export { checkSearchIndexVersion, searchHealthCheck } from "./search" export { deleteTestimony, diff --git a/functions/src/profile/completePhoneVerification.ts b/functions/src/profile/completePhoneVerification.ts new file mode 100644 index 000000000..b5a28bed2 --- /dev/null +++ b/functions/src/profile/completePhoneVerification.ts @@ -0,0 +1,25 @@ +import * as functions from "firebase-functions" +import { db, auth } from "../firebase" +import { checkAuth, fail } from "../common" + +export const completePhoneVerification = functions.https.onCall( + async (_, context) => { + const uid = checkAuth(context) + + const user = await auth.getUser(uid) + const hasPhone = user.providerData?.some(p => p.providerId === "phone") + + if (!hasPhone) { + throw fail( + "failed-precondition", + "Phone number is not linked to this account. Complete phone verification first." + ) + } + + await db + .doc(`/profiles/${uid}`) + .set({ phoneVerified: true }, { merge: true }) + + return { phoneVerified: true } + } +) diff --git a/functions/src/profile/index.ts b/functions/src/profile/index.ts index a897f8e16..1b6fde6df 100644 --- a/functions/src/profile/index.ts +++ b/functions/src/profile/index.ts @@ -1 +1,2 @@ +export * from "./completePhoneVerification" export * from "./finishSignup" diff --git a/functions/src/profile/types.ts b/functions/src/profile/types.ts index 733a558df..cd2e2e265 100644 --- a/functions/src/profile/types.ts +++ b/functions/src/profile/types.ts @@ -33,7 +33,8 @@ export const Profile = Record({ profileImage: Optional(String), billsFollowing: Optional(Array(String)), contactInfo: Optional(Dictionary(String)), - location: Optional(String) + location: Optional(String), + phoneVerified: Optional(Boolean) }) export type Profile = Static diff --git a/public/images/verifiedUser.png b/public/images/verifiedUser.png new file mode 100644 index 0000000000000000000000000000000000000000..049f65ce00597314c9148e285febea8867002b52 GIT binary patch literal 1151 zcmV-_1c3XAP)()Cc+1RyfafuwZ}1FGE?fN9pZd&ZlfND<(Bg}_Ub`4k|1 z0!Sa*02s@P_FEuW);fkg@S(HjZ^C-ZoKFC%vTnXW0A%g|1ynoptO+L}qkGG-H zRG5u<05E7S08xM)g+F}}I*fBtGv_h@vXp90*;fk&&}w-jz*7$Z9#~5Z=Kv;`06>IS z1p~01lnLPB3Y$W>6pMHuXAyV8_JRSlQ6SJZKLUdu0Q8!kej7OYBI{a#FS1N-DIu@4 zQi9|u<|bEAF^5Z4P7_ca+hVe#56gx|a(zQ{4V((Ly<^)vPq&nD_{=c?S2p#shnxEM z9;@5|R7VyLKvQ}f;^DE%f)2Kl`coarbFej}A8Z-&ylRkidGoK}Rj^~g$&Srac_9i= ze=LZb4vMXw0R$VR4ZD8sn@wJ<#tbPc*m#C~fVckGiGlhKED0xO%fh6@6M#@8-4f!{ zSiz@p_b)wIf9`K=INyp5zqR6x*uK0_%3$&2Kq1v92PLo|yw+D(A;E1*2Kjw6tUA#jkMY)+d@s+n4ywkqhYOL@ecquDB)> z0GE7ST$B9NRe*{}`edjvjsFJ_jLNuGS0F0mrXRaB^X~-+CvhW}IN_T4?LVbHs%RX4 zZU8RWi$G2L$F2Z`_=z7vjpJws;ESX8FW3PnVG!ZuA$I^G=}}t%yfn4p z;K*8kt?kKX@5IUfN&I%hU`Y&9e6eyI#iK zF80?6Z_NON`ndQ3ST0e{%PmHLKve$Ac-_wle;NUNb#eD|zO87&qOH6v%#XksIb^(C z78V13PM}!;F#$F0-u)}N&?t{c#X(*k4o0QR`L37NiI++_F~z2o_wHbM#yf;zctQhw R15f|}002ovPDHLkV1hDC1aANU literal 0 HcmV?d00001 diff --git a/public/locales/en/editProfile.json b/public/locales/en/editProfile.json index 9f8e4402b..7d873d1c4 100644 --- a/public/locales/en/editProfile.json +++ b/public/locales/en/editProfile.json @@ -1,5 +1,25 @@ { "header": "Edit Profile", + "getVerified": "Get Verified", + "phoneVerificationModalTitle": "Verify your phone number", + "phoneVerification": { + "phoneLabel": "Phone number (Ex 617 555-1234)", + "phonePlaceholder": "617 555-1234", + "continue": "Continue", + "codeLabel": "Verification code", + "codePlaceholder": "Enter 6-digit code", + "verify": "Verify", + "errors": { + "credentialAlreadyInUse": "This phone number is already linked to another account.", + "providerAlreadyLinked": "This account already has a phone number linked.", + "invalidPhoneNumber": "Please enter a valid phone number\n(e.g. 617 555-1234).", + "operationNotAllowed": "Phone verification is not enabled. Please try again later or contact us at info@mapletestimony.org." + }, + "signedInRequired": "You must be signed in to verify your phone.", + "enterVerificationCode": "Please enter the verification code." + }, + "verifiedUser": "Verified User", + "verifiedUserBadgeAlt": "Verified user badge", "setting": "Settings", "privacySetting": "Privacy Settings", "save": "Save", From 6d9dfe1c0b33fda1d23c816e1c54684ff69e2762 Mon Sep 17 00:00:00 2001 From: mertbagt <73559781+mertbagt@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:26:19 -0400 Subject: [PATCH 3/6] timestamp conflict resolution (#2088) --- components/hearing/Transcriptions.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/hearing/Transcriptions.tsx b/components/hearing/Transcriptions.tsx index 27d0280d3..b451c0917 100644 --- a/components/hearing/Transcriptions.tsx +++ b/components/hearing/Transcriptions.tsx @@ -200,7 +200,7 @@ export const Transcriptions = ({ const resultString: string = convertToString(startTime) let currentIndex = transcriptData.findIndex( - element => parseInt(resultString, 10) <= element.end / 1000 + element => parseInt(resultString, 10) < element.end / 1000 ) // Set the initial scroll target when we have a startTime and transcripts @@ -233,7 +233,7 @@ export const Transcriptions = ({ const handleTimeUpdate = () => { videoLoaded ? (currentIndex = transcriptData.findIndex( - element => videoRef.current.currentTime <= element.end / 1000 + element => videoRef.current.currentTime < element.end / 1000 )) : null if (containerRef.current && currentIndex !== highlightedId) { From 2a4f6868d304c0a0a05b9f0df69d972ac5744116 Mon Sep 17 00:00:00 2001 From: Mephistic Date: Tue, 31 Mar 2026 18:07:04 -0400 Subject: [PATCH 4/6] Edit profile follow up #2 (#2092) * partial commit of followers tab refactor * refactor topic subscription query utils * confirm unfollows in following tab * refactor: rename LoadableList to LoadableListState for consistency * minor style edits * reorganize edit profile function locations * fix(i18n): Update translation keys for follow/unfollow buttons * refactor: Update LoadableItemsState type to enforce object constraint and adjust follower fetching logic * change i18n namespace for confirmation modal * fix(translation): Moving unfollow confirmation translation keys to common.json because the component in question is intended for use across multiple pages --------- Co-authored-by: jicruz96 --- components/EditProfilePage/FollowUserCard.tsx | 59 ++++ components/EditProfilePage/FollowersTab.tsx | 142 +++------ components/EditProfilePage/FollowingTab.tsx | 274 +++++++----------- .../FollowingTabComponents.tsx | 157 ---------- components/EditProfilePage/UnfollowModal.tsx | 74 ----- components/shared/FollowButton.tsx | 220 ++++++++------ components/shared/FollowingQueries.tsx | 152 +++------- components/shared/PaginatedItemsCard.tsx | 79 +++++ .../TestimonyDetailPage/PolicyActions.tsx | 12 +- .../TestimonyDetailPage.tsx | 27 +- public/locales/en/common.json | 8 +- public/locales/en/editProfile.json | 5 - 12 files changed, 491 insertions(+), 718 deletions(-) create mode 100644 components/EditProfilePage/FollowUserCard.tsx delete mode 100644 components/EditProfilePage/FollowingTabComponents.tsx delete mode 100644 components/EditProfilePage/UnfollowModal.tsx create mode 100644 components/shared/PaginatedItemsCard.tsx diff --git a/components/EditProfilePage/FollowUserCard.tsx b/components/EditProfilePage/FollowUserCard.tsx new file mode 100644 index 000000000..66de8fa7a --- /dev/null +++ b/components/EditProfilePage/FollowUserCard.tsx @@ -0,0 +1,59 @@ +import { usePublicProfile } from "components/db" +import { Internal } from "components/links" +import { FollowUserButton } from "components/shared/FollowButton" +import { useTranslation } from "next-i18next" +import React from "react" +import { Col, Row, Spinner } from "../bootstrap" +import { OrgIconSmall } from "./StyledEditProfileComponents" + +export function FollowUserCard({ + profileId, + confirmUnfollow +}: { + profileId: string + confirmUnfollow?: boolean +}) { + const { result: profile, loading } = usePublicProfile(profileId) + const { t } = useTranslation("profile") + + if (loading) { + return ( +
+ + + +
+
+ ) + } + + const { fullName, profileImage, public: isPublic } = profile || {} + const displayName = isPublic && fullName ? fullName : t("anonymousUser") + + return ( +
+ + + + {isPublic ? ( + {displayName} + ) : ( + {displayName} + )} + + {isPublic ? ( + + + + ) : null} + +
+
+ ) +} diff --git a/components/EditProfilePage/FollowersTab.tsx b/components/EditProfilePage/FollowersTab.tsx index 384fdc167..b03561bed 100644 --- a/components/EditProfilePage/FollowersTab.tsx +++ b/components/EditProfilePage/FollowersTab.tsx @@ -1,15 +1,13 @@ import { functions } from "components/firebase" import { httpsCallable } from "firebase/functions" import { useTranslation } from "next-i18next" -import { Dispatch, ReactNode, SetStateAction, useEffect, useState } from "react" +import { Dispatch, SetStateAction, useEffect, useState } from "react" import { useAuth } from "../auth" -import { usePublicProfile } from "components/db" -import { Internal } from "components/links" -import { FollowUserButton } from "components/shared/FollowButton" -import React from "react" -import { Col, Row, Spinner, Stack, Alert } from "../bootstrap" -import { TitledSectionCard } from "../shared" -import { OrgIconSmall } from "./StyledEditProfileComponents" +import { FollowUserCard } from "./FollowUserCard" +import { + LoadableItemsState, + PaginatedItemsCard +} from "components/shared/PaginatedItemsCard" export const FollowersTab = ({ className, @@ -19,99 +17,51 @@ export const FollowersTab = ({ setFollowerCount: Dispatch> }) => { const uid = useAuth().user?.uid - const [followerIds, setFollowerIds] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const [state, setState] = useState>( + { + items: [], + loading: true, + error: null + } + ) const { t } = useTranslation("editProfile") + const fetchFollowers = async () => { + try { + const { data: profileIds } = await httpsCallable( + functions, + "getFollowers" + )() + setState({ + items: profileIds.map(profileId => ({ profileId })), + loading: false, + error: null + }) + setFollowerCount(profileIds.length) + } catch (err) { + console.error("Error fetching followerIds", err) + setState({ + items: [], + loading: false, + error: t("content.error") + }) + } + } useEffect(() => { - const fetchFollowers = async () => { - try { - const { data: followerIds } = await httpsCallable( - functions, - "getFollowers" - )() - setFollowerIds(followerIds) - setFollowerCount(followerIds.length) - setLoading(false) - } catch (err) { - console.error("Error fetching followerIds", err) - setError("Error fetching followers.") - setLoading(false) - return - } + if (uid) { + setState(prev => ({ ...prev, loading: true, error: null })) + fetchFollowers() + } else { + setState({ items: [], loading: false, error: null }) } - if (uid) fetchFollowers() }, [uid]) return ( - -
- -

{t("follow.your_followers")}

-

- {t("follow.follower_info_disclaimer")} -

-
- {error ? ( - {error} - ) : loading ? ( - - ) : ( - followerIds.map((profileId, i) => ( - - )) - )} -
-
-
-
- ) -} - -const FollowerCard = ({ profileId }: { profileId: string }) => { - const { result: profile, loading } = usePublicProfile(profileId) - const { t } = useTranslation("profile") - if (loading) { - return ( - - - - ) - } - const { fullName, profileImage, public: isPublic } = profile || {} - const displayName = isPublic && fullName ? fullName : t("anonymousUser") - return ( - - - - {isPublic ? ( - {displayName} - ) : ( - {displayName} - )} - - {isPublic ? ( - - - - ) : ( - <> - )} - + ) } - -const FollowerCardWrapper = ({ children }: { children: ReactNode }) => ( -
- - {children} - -
-
-) diff --git a/components/EditProfilePage/FollowingTab.tsx b/components/EditProfilePage/FollowingTab.tsx index ae9231979..0c5a33e0d 100644 --- a/components/EditProfilePage/FollowingTab.tsx +++ b/components/EditProfilePage/FollowingTab.tsx @@ -1,195 +1,125 @@ -import { collection, getDocs, query, where } from "firebase/firestore" +import { useBill } from "components/db" +import { formatBillId } from "components/formatting" +import { Internal } from "components/links" +import { FollowBillButton } from "components/shared/FollowButton" +import { collection, onSnapshot, query, where } from "firebase/firestore" import { useTranslation } from "next-i18next" -import { useCallback, useEffect, useMemo, useState } from "react" +import { ComponentProps, useEffect, useMemo, useState } from "react" import { useAuth } from "../auth" -import { Stack } from "../bootstrap" +import { Alert, Col, Row, Spinner } from "../bootstrap" import { firestore } from "../firebase" -import { TitledSectionCard } from "../shared" -import UnfollowItem, { UnfollowModalConfig } from "./UnfollowModal" -import { FollowedItem } from "./FollowingTabComponents" -import { BillElement, UserElement } from "./FollowingTabComponents" -import { deleteItem } from "components/shared/FollowingQueries" -import { PaginationButtons } from "../table" +import { FollowUserCard } from "./FollowUserCard" +import { + LoadableItemsState, + PaginatedItemsCard +} from "components/shared/PaginatedItemsCard" export function FollowingTab({ className }: { className?: string }) { - const { user } = useAuth() - const uid = user?.uid + const { t } = useTranslation("editProfile") + return ( + <> + + } + {...useFollowedUsers()} + /> + + ) +} + +const useFollowedBills = (): LoadableItemsState< + ComponentProps +> => useTopicSubscription("bill") + +const useFollowedUsers = (): LoadableItemsState< + ComponentProps +> => useTopicSubscription("testimony") + +function useTopicSubscription( + type: "bill" | "testimony" +): LoadableItemsState { + const [state, setState] = useState>({ + items: [], + loading: false, + error: null + }) + const { t } = useTranslation("editProfile") + const uid = useAuth().user?.uid const subscriptionRef = useMemo( () => - // returns new object only if uid changes uid ? collection(firestore, `/users/${uid}/activeTopicSubscriptions/`) : null, [uid] ) - - const [unfollow, setUnfollow] = useState(null) - const close = () => setUnfollow(null) - - const [billsFollowing, setBillsFollowing] = useState([]) - const [usersFollowing, setUsersFollowing] = useState([]) - - const [currentBillsPage, setCurrentBillsPage] = useState(1) - const [currentUsersPage, setCurrentUsersPage] = useState(1) - const itemsPerPage = 10 - - const billsFollowingQuery = useCallback(async () => { - if (!subscriptionRef) return // handle the case where subscriptionRef is null - const billList: BillElement[] = [] - const q = query( - subscriptionRef, - where("uid", "==", `${uid}`), - where("type", "==", "bill") - ) - const querySnapshot = await getDocs(q) - querySnapshot.forEach(doc => { - // doc.data() is never undefined for query doc snapshots - billList.push(doc.data().billLookup) - }) - if (billsFollowing.length === 0 && billList.length != 0) { - setBillsFollowing(billList) - } // this limits the code from falling into an infinite loop - }, [subscriptionRef, uid, billsFollowing]) + const topicKey = type === "bill" ? "billLookup" : "userLookup" useEffect(() => { - uid ? billsFollowingQuery() : null - }, [uid, billsFollowingQuery]) - - const orgsFollowingQuery = useCallback(async () => { - if (!subscriptionRef) return // handle the case where subscriptionRef is null - const usersList: UserElement[] = [] - const q = query( - subscriptionRef, - where("uid", "==", `${uid}`), - where("type", "==", "testimony") + if (!subscriptionRef || !uid) return + + setState(prev => ({ ...prev, loading: true, error: null })) + const unsubscribe = onSnapshot( + query( + subscriptionRef, + where("uid", "==", uid), + where("type", "==", type) + ), + snap => + setState({ + items: snap.docs.map(doc => doc.data()[topicKey]), + loading: false, + error: null + }), + err => { + console.error(`Error listening to followed ${type}`, err) + setState(prev => ({ + ...prev, + loading: false, + error: t("content.error") + })) + } ) - const querySnapshot = await getDocs(q) - querySnapshot.forEach(doc => { - // doc.data() is never undefined for query doc snapshots - usersList.push(doc.data().userLookup) - }) - - if (usersFollowing.length === 0 && usersList.length != 0) { - setUsersFollowing(usersList) - } // this limits the code from falling into an infinite loop - }, [subscriptionRef, uid, usersFollowing]) - - const fetchFollowedItems = useCallback(async () => { - if (uid) { - billsFollowingQuery() - orgsFollowingQuery() - } - }, [uid, billsFollowingQuery, orgsFollowingQuery]) - - useEffect(() => { - fetchFollowedItems() - }, [billsFollowing, usersFollowing, fetchFollowedItems]) - - const handleUnfollowClick = async (unfollow: UnfollowModalConfig | null) => { - if (!unfollow || !unfollow.typeId) { - // handle the case where unfollow is null or unfollow.typeId is undefined - console.error( - "handleUnfollowClick was called but unfollow or unfollow.typeId is undefined" - ) - return - } - if (unfollow === null) { - return - } - try { - deleteItem({ uid, unfollowItem: unfollow }) - } catch (error: any) { - console.log(error.message) - } + return () => unsubscribe() + }, [subscriptionRef, uid, type]) - setBillsFollowing([]) - setUsersFollowing([]) - setUnfollow(null) - } - - const getPaginatedBills = () => { - const startIndex = (currentBillsPage - 1) * itemsPerPage - const endIndex = startIndex + itemsPerPage - return billsFollowing.slice(startIndex, endIndex) - } - - const getPaginatedUsers = () => { - const startIndex = (currentUsersPage - 1) * itemsPerPage - const endIndex = startIndex + itemsPerPage - return usersFollowing.slice(startIndex, endIndex) - } - - const totalBillsPages = Math.ceil(billsFollowing.length / itemsPerPage) - const totalUsersPages = Math.ceil(usersFollowing.length / itemsPerPage) + return state +} +function FollowedBillCard({ + court, + billId +}: { + court: number + billId: string +}) { + const { loading, error, result: bill } = useBill(court, billId) const { t } = useTranslation("editProfile") + if (loading) return + if (error) return {t("content.error")} + if (!bill) return null return ( - <> - -
- -

{t("follow.bills")}

- {getPaginatedBills().map((element: BillElement, index: number) => ( - - ))} - {billsFollowing.length > 0 && ( - 1, - nextPage: () => setCurrentBillsPage(prev => prev + 1), - previousPage: () => setCurrentBillsPage(prev => prev - 1), - itemsPerPage - }} - /> - )} -
-
-
- -
- -

{t("follow.orgs")}

- {getPaginatedUsers().map((element: UserElement, index: number) => ( - - ))} - {usersFollowing.length > 0 && ( - 1, - nextPage: () => setCurrentUsersPage(prev => prev + 1), - previousPage: () => setCurrentUsersPage(prev => prev - 1), - itemsPerPage - }} - /> - )} -
-
-
- setUnfollow(null)} - show={unfollow ? true : false} - unfollowItem={unfollow} - /> - +
+ + + {formatBillId(billId)} + + +
{bill.content.Title}
+ + + + +
+
+
) } diff --git a/components/EditProfilePage/FollowingTabComponents.tsx b/components/EditProfilePage/FollowingTabComponents.tsx deleted file mode 100644 index 2ac6ec7bb..000000000 --- a/components/EditProfilePage/FollowingTabComponents.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { useTranslation } from "next-i18next" -import { Alert, Row, Spinner } from "../bootstrap" -import { useBill, usePublicProfile } from "components/db" -import { Dispatch, SetStateAction } from "react" -import { Col } from "../bootstrap" -import { TextButton } from "../buttons" -import { UnfollowModalConfig } from "./UnfollowModal" -import { formatBillId } from "components/formatting" -import { Internal } from "components/links" -import { OrgIconSmall } from "./StyledEditProfileComponents" - -export function BillFollowingTitle({ - court, - id -}: { - court: number - id: string -}) { - const { loading, error, result: bill } = useBill(court, id) - const { t } = useTranslation("editProfile") - if (loading) { - return ( - - - - ) - } else if (error) { - return {t("content.error")} - } else if (bill) { - return
{bill?.content.Title}
- } - return null -} - -export type BillElement = { - court: number - billId: string -} -export type UserElement = { - profileId: string - fullName: string -} - -export type Element = BillElement | UserElement - -export const isBillElement = (element: Element): element is BillElement => { - return (element as BillElement).billId !== undefined -} - -export const isUserElement = (element: Element): element is UserElement => { - return (element as UserElement).profileId !== undefined -} - -export function UnfollowButton({ - fullName, - element, - setUnfollow, - type -}: { - fullName: string - element: Element - setUnfollow: Dispatch> - type: string -}) { - const handleClick = () => { - if (!element) { - console.error("handleClick was called but element is undefined") - return - } - if (isBillElement(element)) { - setUnfollow({ - court: element.court, - userName: "", - type: "bill", - typeId: element.billId - }) - } else { - setUnfollow({ - court: 0, - userName: fullName, - type: "testimony", - typeId: element.profileId - }) - } - } - const { t } = useTranslation("editProfile") - return ( - { - handleClick() - }} - > - - - ) -} -export function FollowedItem({ - element, - setUnfollow, - type -}: { - index: number - element: Element - setUnfollow: Dispatch> - type: string -}) { - const elementId = isUserElement(element) ? element.profileId : element.billId - - const { result: profile, loading } = usePublicProfile(elementId) - - if (loading) { - return - } - if (!element) { - console.log("element is undefined") - return null - } - - return ( -
- - {isBillElement(element) ? ( - <> - - {formatBillId(element.billId)} - - - - - - ) : ( - <> - - - - {profile?.fullName} - - - - )} - - -
-
- ) -} diff --git a/components/EditProfilePage/UnfollowModal.tsx b/components/EditProfilePage/UnfollowModal.tsx deleted file mode 100644 index c5d24a1f2..000000000 --- a/components/EditProfilePage/UnfollowModal.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import type { ModalProps } from "react-bootstrap" -import styled from "styled-components" -import { Button, Modal, Stack } from "../bootstrap" -import { formatBillId } from "../formatting" -import { useTranslation } from "next-i18next" -import { FillButton, OutlineButton } from "components/buttons" - -type Props = Pick & { - handleUnfollowClick: ( - unfollowItem: UnfollowModalConfig | null - ) => Promise - onHide: () => void - onUnfollowClose: () => void - show: boolean - unfollowItem: UnfollowModalConfig | null -} - -export type UnfollowModalConfig = { - court: number - userName: string - type: string - typeId: string -} - -export default function UnfollowItem({ - handleUnfollowClick, - onHide, - onUnfollowClose, - show, - unfollowItem -}: Props) { - const { t } = useTranslation("editProfile") - - const handleTopic = () => { - if (unfollowItem?.type == "bill") { - return ` Bill ${formatBillId(unfollowItem?.typeId)}` - } else { - return ` ${unfollowItem?.userName}` - } - } - - return ( - - - {t("follow.unfollow")} - - -
- {t("confirmation.unfollowMessage")} - {handleTopic()}? -
-
- - { - handleUnfollowClick(unfollowItem) - }} - label={t("confirmation.yes")} - /> -
-
-
- ) -} diff --git a/components/shared/FollowButton.tsx b/components/shared/FollowButton.tsx index 87fa5ec81..701a79886 100644 --- a/components/shared/FollowButton.tsx +++ b/components/shared/FollowButton.tsx @@ -1,131 +1,187 @@ import { StyledImage } from "components/ProfilePage/StyledProfileComponents" import { useTranslation } from "next-i18next" -import { useEffect, useContext } from "react" +import { useEffect, useContext, useMemo, useState } from "react" import { Button } from "react-bootstrap" import { useAuth } from "../auth" import { Bill } from "../db" -import { TopicQuery, setFollow, setUnfollow } from "./FollowingQueries" +import { + followsTopic, + followBill, + followProfile, + unfollowBill, + unfollowProfile, + billTopicName, + profileTopicName +} from "./FollowingQueries" import { FollowContext } from "./FollowContext" +import { Modal } from "components/bootstrap" +import { FillButton, OutlineButton } from "components/buttons" +import { formatBillId } from "components/formatting" + +export function FollowUserButton({ + profileId, + confirmFollow, + confirmUnfollow, + userName +}: { + profileId: string + confirmFollow?: boolean + confirmUnfollow?: boolean + userName?: string +}) { + const uid = useAuth().user?.uid + return ( + followProfile(uid, profileId)} + unfollowAction={() => unfollowProfile(uid, profileId)} + confirmFollow={confirmFollow} + confirmUnfollow={confirmUnfollow} + displayName={userName} + /> + ) +} + +export function FollowBillButton({ + bill, + confirmFollow, + confirmUnfollow +}: { + bill: Bill + confirmFollow?: boolean + confirmUnfollow?: boolean +}) { + const uid = useAuth().user?.uid + return ( + followBill(uid, bill)} + unfollowAction={() => unfollowBill(uid, bill)} + confirmFollow={confirmFollow} + confirmUnfollow={confirmUnfollow} + displayName={useTranslation("testimony").t("bill", { + billId: formatBillId(bill.id) + })} + /> + ) +} export const BaseFollowButton = ({ topicName, followAction, unfollowAction, - hide + hide, + confirmFollow = false, + confirmUnfollow = false, + displayName = "" }: { topicName: string followAction: () => Promise unfollowAction: () => Promise hide?: boolean + confirmFollow?: boolean + confirmUnfollow?: boolean + displayName?: string }) => { - const { t } = useTranslation(["profile"]) - - const { user } = useAuth() - const uid = user?.uid - + const { t } = useTranslation("common") + const uid = useAuth().user?.uid const { followStatus, setFollowStatus } = useContext(FollowContext) + const [modalAction, setModalAction] = useState<"follow" | "unfollow" | null>( + null + ) useEffect(() => { - uid - ? TopicQuery(uid, topicName).then(result => { - setFollowStatus(prevOrgFollowGroup => { - return { ...prevOrgFollowGroup, [topicName]: Boolean(result) } - }) - }) - : null + if (!uid) return + followsTopic(uid, topicName).then(result => + setFollowStatus(prev => ({ ...prev, [topicName]: result })) + ) }, [uid, topicName, setFollowStatus]) const FollowClick = async () => { await followAction() - setFollowStatus({ ...followStatus, [topicName]: true }) + setFollowStatus(prev => ({ ...prev, [topicName]: true })) } const UnfollowClick = async () => { await unfollowAction() - setFollowStatus({ ...followStatus, [topicName]: false }) + setFollowStatus(prev => ({ ...prev, [topicName]: false })) } const isFollowing = followStatus[topicName] - const text = isFollowing ? t("button.following") : t("button.follow") - const checkmark = isFollowing ? ( - - ) : null - const handleClick = (event: React.FormEvent) => { - event.preventDefault() - isFollowing ? UnfollowClick() : FollowClick() - } + const onClick = isFollowing + ? () => (confirmUnfollow ? setModalAction("unfollow") : UnfollowClick()) + : () => (confirmFollow ? setModalAction("follow") : FollowClick()) return ( <> {!hide && ( - +
+ +
)} + setModalAction(null)} + onConfirm={() => + modalAction === "follow" ? FollowClick() : UnfollowClick() + } + /> ) } -export const ButtonWithCheckmark = ({ - checkmark, - handleClick, - text, - className -}: { - checkmark: JSX.Element | null - handleClick: any - text: string - className?: string -}) => { - return ( -
- -
- ) -} - -export function FollowUserButton({ - className, - profileId +function ConfirmFollowModal({ + action, + displayName, + onCancel, + onConfirm }: { - className?: string - profileId: string + action: "follow" | "unfollow" | null + displayName: string + onCancel: () => void + onConfirm: () => void | Promise }) { - const { user } = useAuth() - const uid = user?.uid - const topicName = `testimony-${profileId}` - const followAction = () => - setFollow(uid, topicName, undefined, undefined, undefined, profileId) - const unfollowAction = () => setUnfollow(uid, topicName) + const { t } = useTranslation("common") - return ( - + const title = useMemo( + () => (action === "unfollow" ? t("button.unfollow") : t("button.follow")), + [action, t] ) -} -export function FollowBillButton({ bill }: { bill: Bill }) { - const { user } = useAuth() - const uid = user?.uid - const { id: billId, court: courtId } = bill - const topicName = `bill-${courtId}-${billId}` - const followAction = () => - setFollow(uid, topicName, bill, billId, courtId, undefined) - const unfollowAction = () => setUnfollow(uid, topicName) + const message = useMemo( + () => t(`confirmation.${action}Message`, { displayName }), + [action, displayName, t] + ) return ( - + + + {title} + + +
{message}
+
+ + +
+
+
) } diff --git a/components/shared/FollowingQueries.tsx b/components/shared/FollowingQueries.tsx index 288da17e2..a73b688b6 100644 --- a/components/shared/FollowingQueries.tsx +++ b/components/shared/FollowingQueries.tsx @@ -1,122 +1,58 @@ -import { - collection, - deleteDoc, - doc, - getDocs, - query, - setDoc, - where -} from "firebase/firestore" +import { collection, deleteDoc, doc, getDoc, setDoc } from "firebase/firestore" import { Bill } from "../db" import { firestore } from "../firebase" -import { UnfollowModalConfig } from "components/EditProfilePage/UnfollowModal" -export type Results = { [key: string]: string[] } - -function setSubscriptionRef(uid: string | undefined) { - return collection(firestore, `/users/${uid}/activeTopicSubscriptions/`) +function getTopicRef(uid: string | undefined, topicName: string) { + return doc( + collection(firestore, `/users/${uid}/activeTopicSubscriptions/`), + topicName + ) } -export async function deleteItem({ - uid, - unfollowItem -}: { - uid: string | undefined - unfollowItem: UnfollowModalConfig | null -}) { - const subscriptionRef = setSubscriptionRef(uid) - - if (unfollowItem !== null) { - let topicName = "" - if (unfollowItem.type == "bill") { - topicName = `bill-${unfollowItem.court.toString()}-${unfollowItem.typeId}` - } else { - topicName = `testimony-${unfollowItem.typeId}` - } - - await deleteDoc(doc(subscriptionRef, topicName)) - } +export const billTopicName = (court: number, billId: string) => + `bill-${court}-${billId}` +export const profileTopicName = (profileId: string) => `testimony-${profileId}` + +export const followBill = async (uid: string | undefined, bill: Bill) => { + const topicName = billTopicName(bill.court, bill.id) + await setDoc(getTopicRef(uid, topicName), { + topicName, + uid, + billLookup: { + billId: bill.id, + court: bill.court + }, + type: "bill" + }) } -export async function FollowingQuery(uid: string | undefined) { - let results: Results = { - bills: [], - orgs: [] - } - - const subscriptionRef = setSubscriptionRef(uid) +const unfollowTopic = async (uid: string | undefined, topicName: string) => + await deleteDoc(getTopicRef(uid, topicName)) - const q1 = query( - subscriptionRef, - where("uid", "==", `${uid}`), - where("type", "==", `bill`) - ) - const querySnapshotBills = await getDocs(q1) - querySnapshotBills.forEach(doc => { - // doc.data() is never undefined for query doc snapshots - doc.data().billLookup ? results.bills.push(doc.data().billLookup) : null - }) +export const unfollowBill = async ( + uid: string | undefined, + bill: Pick +) => await unfollowTopic(uid, billTopicName(bill.court, bill.id)) - const q2 = query( - subscriptionRef, - where("uid", "==", `${uid}`), - where("type", "==", `org`) - ) - const querySnapshotOrgs = await getDocs(q2) - querySnapshotOrgs.forEach(doc => { - // doc.data() is never undefined for query doc snapshots - doc.data().userLookup ? results.orgs.push(doc.data().userLookup) : null +export const followProfile = async ( + uid: string | undefined, + profileId: string +) => { + const topicName = profileTopicName(profileId) + await setDoc(getTopicRef(uid, topicName), { + topicName, + uid, + userLookup: { profileId }, + type: "testimony" }) - - return results } -export async function setFollow( +export const unfollowProfile = async ( uid: string | undefined, - topicName: string, - bill: Bill | undefined, - billId: string | undefined, - courtId: number | undefined, - profileId: string | undefined -) { - const subscriptionRef = setSubscriptionRef(uid) - - bill - ? await setDoc(doc(subscriptionRef, topicName), { - topicName: topicName, - uid: uid, - billLookup: { - billId: billId, - court: courtId - }, - type: "bill" - }) - : await setDoc(doc(subscriptionRef, topicName), { - topicName: topicName, - uid: uid, - userLookup: { - profileId: profileId - }, - type: "testimony" - }) -} - -export async function setUnfollow(uid: string | undefined, topicName: string) { - const subscriptionRef = setSubscriptionRef(uid) - - await deleteDoc(doc(subscriptionRef, topicName)) -} + profileId: string +) => await unfollowTopic(uid, profileTopicName(profileId)) -export async function TopicQuery(uid: string | undefined, topicName: string) { - let result = "" - - const subscriptionRef = setSubscriptionRef(uid) - - const q = query(subscriptionRef, where("topicName", "==", topicName)) - const querySnapshot = await getDocs(q) - querySnapshot.forEach(doc => { - // doc.data() is never undefined for query doc snapshots - result = doc.data().topicName - }) - return result -} +export const followsTopic = async ( + uid: string | undefined, + topicName: string +) => !!uid && (await getDoc(getTopicRef(uid, topicName))).exists() diff --git a/components/shared/PaginatedItemsCard.tsx b/components/shared/PaginatedItemsCard.tsx new file mode 100644 index 000000000..a223e96a1 --- /dev/null +++ b/components/shared/PaginatedItemsCard.tsx @@ -0,0 +1,79 @@ +import React, { useEffect, useMemo, useState } from "react" +import { Alert, Spinner, Stack } from "../bootstrap" +import { TitledSectionCard } from "../shared" +import { PaginationButtons } from "../table" + +export type LoadableItemsState = { + items: readonly T[] + loading: boolean + error: string | null +} + +export function PaginatedItemsCard({ + className, + title, + items, + itemsPerPage = 10, + ItemCard, + loading = false, + error = null, + description +}: LoadableItemsState & { + className?: string + title: string | React.ReactNode + itemsPerPage?: number + ItemCard: React.ComponentType + description?: React.ReactNode +}) { + const [currentPage, setCurrentPage] = useState(1) + + const totalPages = useMemo( + () => Math.max(1, Math.ceil(items.length / itemsPerPage)), + [items.length, itemsPerPage] + ) + + useEffect(() => { + // Reset or clamp page when the list changes or page size changes + setCurrentPage(prev => Math.min(Math.max(1, prev), totalPages)) + }, [items.length, itemsPerPage, totalPages]) + + const paginatedItems = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage + const endIndex = startIndex + itemsPerPage + return items.slice(startIndex, endIndex) + }, [items, currentPage, itemsPerPage]) + + return ( + +
+ +

{title}

+ {description &&
{description}
} +
+ {error ? ( + {error} + ) : loading ? ( + + ) : ( + paginatedItems.map((item, index) => ( + + )) + )} +
+ {!loading && items.length > 0 && ( + 1, + nextPage: () => setCurrentPage(prev => prev + 1), + previousPage: () => setCurrentPage(prev => prev - 1), + itemsPerPage + }} + /> + )} +
+
+
+ ) +} diff --git a/components/testimony/TestimonyDetailPage/PolicyActions.tsx b/components/testimony/TestimonyDetailPage/PolicyActions.tsx index 90d8c4d52..b79c1d3f9 100644 --- a/components/testimony/TestimonyDetailPage/PolicyActions.tsx +++ b/components/testimony/TestimonyDetailPage/PolicyActions.tsx @@ -6,7 +6,7 @@ import { FC, ReactElement, useContext, useEffect } from "react" import { useCurrentTestimonyDetails } from "./testimonyDetailSlice" import { useTranslation } from "next-i18next" import { useAuth } from "components/auth" -import { TopicQuery } from "components/shared/FollowingQueries" +import { followsTopic } from "components/shared/FollowingQueries" import { StyledImage } from "components/ProfilePage/StyledProfileComponents" import { FollowContext } from "components/shared/FollowContext" @@ -44,22 +44,20 @@ export const PolicyActions: FC> = ({ useEffect(() => { uid - ? TopicQuery(uid, topicName).then(result => { - setFollowStatus(prevOrgFollowGroup => { - return { ...prevOrgFollowGroup, [topicName]: Boolean(result) } - }) + ? followsTopic(uid, topicName).then(result => { + setFollowStatus(prev => ({ ...prev, [topicName]: result })) }) : null }, [uid, topicName, setFollowStatus]) const FollowClick = async () => { await followAction() - setFollowStatus({ ...followStatus, [topicName]: true }) + setFollowStatus(prev => ({ ...prev, [topicName]: true })) } const UnfollowClick = async () => { await unfollowAction() - setFollowStatus({ ...followStatus, [topicName]: false }) + setFollowStatus(prev => ({ ...prev, [topicName]: false })) } const isFollowing = followStatus[topicName] diff --git a/components/testimony/TestimonyDetailPage/TestimonyDetailPage.tsx b/components/testimony/TestimonyDetailPage/TestimonyDetailPage.tsx index 82a76d9f5..abba5800d 100644 --- a/components/testimony/TestimonyDetailPage/TestimonyDetailPage.tsx +++ b/components/testimony/TestimonyDetailPage/TestimonyDetailPage.tsx @@ -15,7 +15,11 @@ import { TestimonyDetail } from "./TestimonyDetail" import { VersionBanner } from "./TestimonyVersionBanner" import { useAuth } from "components/auth" import { useMediaQuery } from "usehooks-ts" -import { setFollow, setUnfollow } from "components/shared/FollowingQueries" +import { + billTopicName, + followBill, + unfollowBill +} from "components/shared/FollowingQueries" export const TestimonyDetailPage: FC> = () => { const [isReporting, setIsReporting] = useState(false) @@ -24,18 +28,9 @@ export const TestimonyDetailPage: FC> = () => { const isMobile = useMediaQuery("(max-width: 768px)") const { authorUid, revision } = useCurrentTestimonyDetails() const { bill } = useCurrentTestimonyDetails() - const { user } = useAuth() - const isUser = user?.uid === authorUid - const handleReporting = (boolean: boolean) => { - setIsReporting(boolean) - } + const uid = useAuth().user?.uid + const isUser = uid === authorUid const { t } = useTranslation("testimony", { keyPrefix: "reportModal" }) - const uid = user?.uid - const { id: billId, court: courtId } = bill - const topicName = `bill-${courtId}-${billId}` - const followAction = () => - setFollow(uid, topicName, bill, billId, courtId, undefined) - const unfollowAction = () => setUnfollow(uid, topicName) return ( <> @@ -56,10 +51,10 @@ export const TestimonyDetailPage: FC> = () => { className="mb-4" isUser={isUser} isReporting={isReporting} - setReporting={handleReporting} - topicName={topicName} - followAction={followAction} - unfollowAction={unfollowAction} + setReporting={setIsReporting} + topicName={billTopicName(bill.court, bill.id)} + followAction={() => followBill(uid, bill)} + unfollowAction={() => unfollowBill(uid, bill)} /> )} diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 419f56be0..a0a3d5fe2 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -73,10 +73,16 @@ "button": { "follow": "Follow", "following": "Following", - "followed": "Followed" + "followed": "Followed", + "unfollow": "Unfollow" }, "calendar": "Our Calendar", "copiedToClipboard": "Copied to Clipboard!", + "confirmation": { + "unfollowMessage": "Are you sure you want to unfollow?", + "yes": "Yes", + "no": "No" + }, "hideAns": "Hide Answer", "date": "{{date, datetime(year: 'numeric'; month: 'long'; day: 'numeric')}}", "edit": "Edit", diff --git a/public/locales/en/editProfile.json b/public/locales/en/editProfile.json index 7d873d1c4..f138871af 100644 --- a/public/locales/en/editProfile.json +++ b/public/locales/en/editProfile.json @@ -52,11 +52,6 @@ "follow": "Follow", "follower_info_disclaimer": "Names and follower counts are not publicly available; only visible to you." }, - "confirmation": { - "unfollowMessage": "Are you sure you want to unfollow", - "yes": "Yes", - "no": "No" - }, "email": { "frequencyQuery": "How often would you like to receive emails?", "daily": "Daily", From 8bcd79761ce0117440c5d0ad35db64eb27a5fcc5 Mon Sep 17 00:00:00 2001 From: Mephistic Date: Tue, 31 Mar 2026 18:33:41 -0400 Subject: [PATCH 5/6] fix(test): Removing MALegislature unit test - it makes a (super long running) call to the actual MA Legislature API, which makes the unit tests slower and more likely to fail. This *may* eventually be worth replacing with an E2E test that hits the legislature and ensures we can parse specific endpoints/documents (#2093) --- functions/src/malegislature.test.ts | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 functions/src/malegislature.test.ts diff --git a/functions/src/malegislature.test.ts b/functions/src/malegislature.test.ts deleted file mode 100644 index 8d63e9a1a..000000000 --- a/functions/src/malegislature.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { listHearings } from "./malegislature" - -jest.setTimeout(40000) -it("works", async () => { - await listHearings() -}) From b7828935e215e4c9dba42c98bf92b56c905a8b72 Mon Sep 17 00:00:00 2001 From: Mephistic Date: Mon, 6 Apr 2026 12:06:16 -0400 Subject: [PATCH 6/6] fix(admin): Fix the Admin Resolve Report function - the Profile types are mismatched with the existing Firestore dev data, but the type check is extraneous since all we need it an (optional) name from the profile. This simply removes the check (the mismatch was on a Timestamp field, so not relevant to the functionality here). (#2097) --- functions/src/testimony/resolveReport.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/functions/src/testimony/resolveReport.ts b/functions/src/testimony/resolveReport.ts index b3170c5dc..181b7c604 100644 --- a/functions/src/testimony/resolveReport.ts +++ b/functions/src/testimony/resolveReport.ts @@ -5,7 +5,6 @@ import { fail, checkRequestZod, checkAuth, checkAdmin } from "../common" // import { performDeleteTestimony } from "./deleteTestimony" import { first } from "lodash" import { Testimony } from "./types" -import { Profile } from "../profile/types" export type Request = z.infer const Request = z.object({ @@ -45,12 +44,10 @@ export const resolveReport = functions.https.onCall( // 3. Get the moderator's profile document const moderatorUid = context.auth!.uid - const moderator = Profile.check( - await db - .doc(`profiles/${moderatorUid}`) - .get() - .then(d => d.data()) - ) + const moderatorName = await db + .doc(`profiles/${moderatorUid}`) + .get() + .then(d => d.data()?.fullName) // ***archived testiomny Id === published testimony Id*** @@ -76,7 +73,7 @@ export const resolveReport = functions.https.onCall( archivedTestimonyId: testimonyId } if (reason) resolutionObj.reason = reason - if (moderator.fullName) resolutionObj.moderatorName = moderator.fullName + if (moderatorName) resolutionObj.moderatorName = moderatorName await reportRef.update({ resolution: resolutionObj