Skip to content
20 changes: 16 additions & 4 deletions components/EditProfilePage/EditProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ import { TestimoniesTab } from "./TestimoniesTab"
import { useFlags } from "components/featureFlags"
import LoginPage from "components/Login/Login"
import { PendingUpgradeBanner } from "components/PendingUpgradeBanner"
import { FollowersTab } from "./FollowersTab"

const tabTitle = ["about-you", "testimonies", "following"] as const
const tabTitle = ["about-you", "testimonies", "following", "followers"] as const
export type TabTitles = (typeof tabTitle)[number]

export default function EditProfile({
Expand Down Expand Up @@ -115,6 +116,7 @@ export function EditProfileForm({
isOrg = isOrg || isPendingUpgrade

const { t } = useTranslation("editProfile")
const [followerCount, setFollowerCount] = useState<number | null>(null)

const tabs = [
{
Expand Down Expand Up @@ -147,6 +149,18 @@ export function EditProfileForm({
title: t("tabs.following"),
eventKey: "following",
content: <FollowingTab className="mt-3 mb-4" />
},
{
title: followerCount
? t("tabs.followersWithCount", { count: followerCount })
: t("tabs.followers"),
eventKey: "followers",
content: (
<FollowersTab
className="mt-3 mb-4"
setFollowerCount={setFollowerCount}
/>
)
}
]

Expand Down Expand Up @@ -174,9 +188,7 @@ export function EditProfileForm({
>
<TabNavWrapper>
{tabs.map((t, i) => (
<>
<TabNavItem tab={t} i={i} />
</>
<TabNavItem key={i} tab={t} i={i} />
))}
</TabNavWrapper>
<StyledTabContent>
Expand Down
113 changes: 113 additions & 0 deletions components/EditProfilePage/FollowersTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { functions } from "components/firebase"
import { httpsCallable } from "firebase/functions"
import type {
GetFollowersRequest,
GetFollowersResponse
} from "functions/src/subscriptions/getFollowers"
import { useTranslation } from "next-i18next"
import { Dispatch, ReactNode, 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 } from "../bootstrap"
import { TitledSectionCard } from "../shared"
import { OrgIconSmall } from "./StyledEditProfileComponents"

export const FollowersTab = ({
className,
setFollowerCount
}: {
className?: string
setFollowerCount: Dispatch<SetStateAction<number | null>>
}) => {
const uid = useAuth().user?.uid
const [followerIds, setFollowerIds] = useState<string[]>([])
const { t } = useTranslation("editProfile")

useEffect(() => {
const fetchFollowers = async (uid: string) => {
try {
const { data: followerIds } = await httpsCallable<
GetFollowersRequest,
GetFollowersResponse
>(
functions,
"getFollowers"
)({ uid })
setFollowerIds(followerIds)
setFollowerCount(followerIds.length)
} catch (err) {
console.error("Error fetching followerIds", err)
return
}
}
if (uid) fetchFollowers(uid)
}, [uid])
return (
<TitledSectionCard className={className}>
<div className={`mx-4 mt-3 d-flex flex-column gap-3`}>
<Stack>
<h2>{t("follow.your_followers")}</h2>
<p className="mt-0 text-muted">
{t("follow.follower_info_disclaimer")}
</p>
<div className="mt-3">
{followerIds.map((profileId, i) => (
<FollowerCard key={i} profileId={profileId} />
))}
</div>
</Stack>
</div>
</TitledSectionCard>
)
}

const FollowerCard = ({ profileId }: { profileId: string }) => {
const { result: profile, loading } = usePublicProfile(profileId)
const { t } = useTranslation("profile")
if (loading) {
return (
<FollowerCardWrapper>
<Spinner animation="border" className="mx-auto" />
</FollowerCardWrapper>
)
}
const { fullName, profileImage, public: isPublic } = profile || {}
const displayName = isPublic && fullName ? fullName : t("anonymousUser")
return (
<FollowerCardWrapper>
<Col className="d-flex align-items-center flex-grow-1 p-0 text-start">
<OrgIconSmall
className="mr-4 mt-0 mb-0 ms-0"
profileImage={profileImage}
/>
{isPublic ? (
<Internal href={`/profile?id=${profileId}`}>{displayName}</Internal>
) : (
<span>{displayName}</span>
)}
</Col>
{isPublic ? (
<Col
xs="auto"
className="d-flex justify-content-end ms-auto text-end p-0"
>
<FollowUserButton profileId={profileId} />
</Col>
) : (
<></>
)}
</FollowerCardWrapper>
)
}

const FollowerCardWrapper = ({ children }: { children: ReactNode }) => (
<div className={`fs-3 lh-lg`}>
<Row className="align-items-center justify-content-between g-0 w-100">
{children}
</Row>
<hr className={`mt-3`} />
</div>
)
11 changes: 5 additions & 6 deletions components/EditProfilePage/StyledEditProfileComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const StyledTabNav = styled(Nav).attrs(props => ({
export const TabNavWrapper = ({ children, className, ...props }: NavProps) => {
return (
<Nav
className={`d-flex mb-3 text-center h3 color-dark ${className}`}
className={`d-flex w-100 flex-column flex-lg-row flex-lg-nowrap mb-3 text-center h3 color-dark ${className}`}
{...props}
>
{children}
Expand All @@ -68,12 +68,11 @@ export const TabNavItem = ({
className?: string
}) => {
return (
<Nav.Item
className={`flex-grow-1 col-12 col-md-auto ${className}`}
key={tab.eventKey}
>
<Nav.Item className={`flex-lg-fill ${className}`} key={tab.eventKey}>
<TabNavLink eventKey={tab.eventKey} className={`rounded-top m-0 p-0`}>
<p className={`my-0 ${i === 0 ? "" : "mx-4"}`}>{tab.title}</p>
<p className={`my-0 text-nowrap ${i === 0 ? "" : "mx-4"}`}>
{tab.title}
</p>
<hr className={`my-0`} />
</TabNavLink>
</Nav.Item>
Expand Down
3 changes: 2 additions & 1 deletion functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ export {
followBill,
unfollowBill,
followUser,
unfollowUser
unfollowUser,
getFollowers
} from "./subscriptions"

export { transcription } from "./webhooks"
Expand Down
46 changes: 46 additions & 0 deletions functions/src/subscriptions/getFollowers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { getFirestore } from "firebase-admin/firestore"
import * as functions from "firebase-functions"

export interface GetFollowersRequest {
uid: string
}
export type GetFollowersResponse = string[]

export const getFollowers = functions.https.onCall(
async ({ uid }: GetFollowersRequest, context) => {
if (!context.auth) {
throw new functions.https.HttpsError(
"failed-precondition",
"The function must be called while authenticated."
)
}

functions.logger.log(
`[getFollowers] Finding followers for user UID: ${uid}`
)

return await getFirestore()
.collectionGroup("activeTopicSubscriptions")
.where("userLookup.profileId", "==", uid)
.get()
.then(snapshot => {
const followers = new Set<string>()
snapshot.forEach(doc => {
const followerUid = doc.ref.parent.parent?.id
if (followerUid && followerUid !== uid) followers.add(followerUid)
})
functions.logger.log(
`[getFollowers] Found ${followers.size} followers for user UID: ${uid}`
)
return Array.from(followers)
})
.catch(error => {
functions.logger.error("[getFollowers] Caught error:", error)
throw new functions.https.HttpsError(
"internal",
"Failed to retrieve followers and reciprocal status.",
error
)
})
}
)
4 changes: 2 additions & 2 deletions functions/src/subscriptions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ import { followBill } from "./followBill"
import { unfollowBill } from "./unfollowBill"
import { followUser } from "./followUser"
import { unfollowUser } from "./unfollowUser"

import { getFollowers } from "./getFollowers"
// export the functions
export { followBill, unfollowBill, followUser, unfollowUser }
export { followBill, unfollowBill, followUser, unfollowUser, getFollowers }
2 changes: 2 additions & 0 deletions pages/edit-profile/[[...docName]].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const getStaticPaths: GetStaticPaths = async ctx => {
{ params: { docName: ["about-you"] } },
{ params: { docName: ["testimonies"] } },
{ params: { docName: ["following"] } },
{ params: { docName: ["followers"] } },
{ params: { docName: [] } }
],
fallback: false
Expand All @@ -36,6 +37,7 @@ export const getStaticProps = createGetStaticTranslationProps([
"auth",
"common",
"editProfile",
"profile",
"footer",
"testimony"
])
13 changes: 9 additions & 4 deletions public/locales/en/editProfile.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
"tabs": {
"personalInfo": "Personal Information",
"testimonies": "Testimonies",
"following": "Following"
"following": "Following",
"followers": "Followers",
"followersWithCount": "Followers ({{count}})"
},
"content": {
"reqPending": "Your request to be an organization account is pending approval",
Expand All @@ -25,7 +27,10 @@
"follow": {
"bills": "Bills I Follow",
"orgs": "Users I Follow",
"unfollow": "Unfollow"
"your_followers": "Your Followers",
"unfollow": "Unfollow",
"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",
Expand All @@ -38,7 +43,7 @@
"weekly": "Weekly",
"monthly": "Monthly"
},
"emailIconAlt":"open envelope with letter, toggles update frequency options",
"emailIconAlt": "open envelope with letter, toggles update frequency options",
"legislator": {
"representative": "Representative",
"searchRepresentative": "Search your representative",
Expand Down Expand Up @@ -100,4 +105,4 @@
"admin": "Your profile is currently public. Others can view your profile page. Admin accounts cannot set their profile to private.",
"default": "The publicity of your profile is unknown. Please contact the admin for more information."
}
}
}