refactor: reference human id instead of email (#73)

* refactor: update api and client to reference humanIds

* fix: prevent deletion of network member

This may cause various side effects if there is data in other services
which reference this member
This commit was merged in pull request #73.
This commit is contained in:
Arjun Patel
2026-03-24 19:48:02 -07:00
committed by GitHub
parent 3bf3f16be7
commit bf0147d542
24 changed files with 666 additions and 416 deletions
@@ -5,6 +5,7 @@ import { useDownloadUrl } from "@/hooks/use-download-url";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Small } from "@/components/ui/typography";
import { getInitials } from "@/lib/utils";
import { useNetwork } from "@/hooks/use-networks";
interface AutoplayOverlayProps {
networkId: string;
@@ -17,12 +18,14 @@ export function AutoplayOverlay({ networkId }: AutoplayOverlayProps) {
const { data: url } = useDownloadUrl(activeParticle?.properties.object_id);
const navigate = useNavigate();
const network = useNetwork(networkId);
if (!activeParticle || !url) return null;
const isVideo = activeParticle.properties.mime_type?.startsWith("video/");
const senderEmail = activeParticle.created_by_email;
const senderInitials = getInitials(senderEmail);
const senderName = senderEmail.split("@")[0];
const creator = network?.humans?.find((h) => h.id === activeParticle.created_by_human_id);
const senderInitials = creator ? getInitials(creator.email) : activeParticle.created_by_human_id.slice(0, 2).toUpperCase();
const senderName = creator?.email_prefix ?? activeParticle.created_by_human_id;
const handleClick = () => {
stop();
@@ -51,7 +51,7 @@ export function FallbackParticleView({ particle }: FallbackParticleViewProps) {
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-xs">
From {particle.created_by_email}
From {particle.created_by_human_id}
</p>
</CardContent>
</Card>
@@ -30,6 +30,7 @@ import type { Particle, StreamProperties } from "@/api/types";
import { useAutoplayStore } from "@/stores/autoplay-store";
import { where, Timestamp } from "firebase/firestore";
import { RECENCY_WINDOW_HOURS } from "@/lib/constants";
import { useNetwork } from "@/hooks/use-networks";
function getParticleTypeIcon(particle: Particle): LucideIcon {
switch (particle.type) {
@@ -88,7 +89,7 @@ function StreamRow({
const { latestChild } = useLiveLatestChild(streamPath);
const user = useAuthStore((s) => s.user);
const userId = user?.id ?? "";
const userEmail = user?.email ?? "";
const network = useNetwork(networkId);
// Autoplay: trigger only when latestChild *changes* to a new media particle,
// not on initial data load. We track the "settled" id — the first non-null value
@@ -106,7 +107,7 @@ function StreamRow({
if (latestChild.id === settledIdRef.current) return;
settledIdRef.current = latestChild.id;
if (latestChild.created_by_email === userEmail) return;
if (latestChild.created_by_human_id === userId) return;
if (latestChild.type === "text") {
new Audio(beepSound).play().catch(() => {});
@@ -125,20 +126,22 @@ function StreamRow({
const initials = useMemo(() => {
if (isDM) {
const otherEntry = particle.visible_to.find(
(v) => v !== `human:${userEmail}`,
(v) => v !== `human:${userId}`,
);
if (otherEntry) {
const otherEmail = otherEntry.replace("human:", "");
return getInitials(otherEmail);
const otherId = otherEntry.replace("human:", "");
const otherHuman = network?.humans?.find((h) => h.id === otherId);
if (otherHuman) return getInitials(otherHuman.email);
}
}
if (latestChild) {
return getInitials(latestChild.created_by_email);
const creator = network?.humans?.find((h) => h.id === latestChild.created_by_human_id);
if (creator) return getInitials(creator.email);
}
return particle.properties.name.slice(0, 2).toUpperCase();
}, [isDM, particle.visible_to, particle.properties.name, userEmail, latestChild]);
}, [isDM, particle.visible_to, particle.properties.name, userId, latestChild, network]);
const isUnseen = useMemo(() => {
if (!latestChild) return false;
@@ -150,17 +153,17 @@ function StreamRow({
const senderPrefix = useMemo(() => {
if (!latestChild) return null;
const isCurrentUser = latestChild.created_by_email === userEmail;
const isCurrentUser = latestChild.created_by_human_id === userId;
if (isDM) {
return isCurrentUser ? "You: " : null;
}
// Group stream
if (isCurrentUser) return "You: ";
const emailPrefix = latestChild.created_by_email.split("@")[0];
const capitalized =
emailPrefix.charAt(0).toUpperCase() + emailPrefix.slice(1);
const creator = network?.humans?.find((h) => h.id === latestChild.created_by_human_id);
const name = creator?.email_prefix ?? latestChild.created_by_human_id;
const capitalized = name.charAt(0).toUpperCase() + name.slice(1);
return `${capitalized}: `;
}, [latestChild, userEmail, isDM]);
}, [latestChild, userId, isDM, network]);
const subtitle = latestChild
? getMessagePreview(latestChild)
@@ -237,19 +240,19 @@ function StreamRow({
// Generates the scopes for filtering particles to those that the user has access to
function useVisibilityScopes(
userEmail?: string,
userId?: string,
networkId?: string,
) {
return useMemo(() => {
let scopes: string[] = [];
if (userEmail) {
scopes.push(`human:${userEmail}`);
if (userId) {
scopes.push(`human:${userId}`);
}
if (networkId) {
scopes.push(`network:${networkId}`);
}
return scopes;
}, [userEmail, networkId]);
}, [userId, networkId]);
}
interface ParticleListViewProps {
@@ -262,7 +265,7 @@ interface ParticleListViewProps {
export function ParticleListView({ path }: ParticleListViewProps) {
const { networkId } = parseParticlePath(path);
const user = useAuthStore((s) => s.user);
const visibilityScopes = useVisibilityScopes(user?.email, networkId);
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
const [recencyCutoff, setRecencyCutoff] = useState(() => {
const d = new Date();
+17 -16
View File
@@ -202,7 +202,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
</p>
<ControlsIndicator type="reply" />
<ComposeOverlay
networkId={networkId!}
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
/>
@@ -267,7 +267,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
</div>
<ComposeOverlay
networkId={networkId!}
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
onParticleCreated={onLocalParticleCreated}
@@ -333,7 +333,7 @@ function TopBar({ networkId, particle, streamParticle }: { networkId: string; pa
<>
<BreadcrumbSeparator />
<BreadcrumbItem className="text-xs">
<BreadcrumbPage><ParticleBreadcrumbContent particle={particle} /></BreadcrumbPage>
<BreadcrumbPage><ParticleBreadcrumbContent particle={particle} networkId={networkId} /></BreadcrumbPage>
</BreadcrumbItem>
</>
)}
@@ -370,10 +370,11 @@ function NetworkBreadcrumbContent({ networkId }: { networkId: string }) {
);
}
function ParticleBreadcrumbContent({ particle }: { particle: Particle }) {
const createdByEmail = particle.created_by_email;
const prefix = createdByEmail.split('@')[0];
const initials = createdByEmail.slice(0, 2).toUpperCase();
function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle; networkId: string }) {
const network = useNetwork(networkId);
const creator = network?.humans?.find((h) => h.id === particle.created_by_human_id);
const prefix = creator?.email_prefix ?? particle.created_by_human_id;
const initials = prefix.slice(0, 2).toUpperCase();
return (
<span className="flex
@@ -398,27 +399,27 @@ const SeenIndicator = ({ stream, currentParticle, networkId }: { stream: Particl
.filter(([userId, timestamp]) => timestamp.getTime() >= currentParticle.created_at.getTime() && userId !== authedUser?.id)
.map(([userId, _]) => userId);
const seenUserEmails = seenUserIds
.map((userId) => network?.humans?.find((h) => h.id === userId)?.email)
.filter((email): email is string => !!email && email !== currentParticle.created_by_email);
const seenHumans = seenUserIds
.map((userId) => network?.humans?.find((h) => h.id === userId))
.filter((h): h is NonNullable<typeof h> => !!h && h.id !== currentParticle.created_by_human_id);
if (seenUserIds.length === 0) return null;
if (seenHumans.length === 0) return null;
return (
<>
{seenUserEmails.length > 0 && "Seen by"}
{"Seen by"}
<AvatarGroup>
{seenUserEmails.map((email) => (
<Tooltip key={email}>
{seenHumans.map((human) => (
<Tooltip key={human.id}>
<TooltipTrigger asChild>
<Avatar size="sm">
<AvatarFallback>
{email.split("@")[0].slice(0, 2)}
{human.email_prefix.slice(0, 2)}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>
<p>Seen by {email.split("@")[0]}</p>
<p>Seen by {human.email_prefix}</p>
</TooltipContent>
</Tooltip>
))}