diff --git a/src/components/AuthModal.tsx b/src/components/AuthModal.tsx
index 82d2d0f..fd115ba 100644
--- a/src/components/AuthModal.tsx
+++ b/src/components/AuthModal.tsx
@@ -15,6 +15,7 @@ import { getSocialLinks } from '@/lib/social-urls';
import { supabase } from '@/lib/supabase';
import { useProfile } from '@/hooks/useProfile';
+import { useXVerification } from '@/hooks/useXVerification';
import { useFriends } from '@/hooks/useFriends';
import { useFriendRequests } from '@/hooks/useFriendRequests';
import type { ETHDenverEvent, NativeAd, UserSearchResult, FriendRequest } from '@/lib/types';
@@ -378,6 +379,7 @@ export function UserMenu({ events, itinerary, onOpenFriends, pendingIncomingCoun
const { friendCount, refreshFriends: localRefreshFriends } = useFriends();
const { config } = useAdminConfig();
const { theme, setTheme } = useTheme();
+ const { isXVerified, linkX, unlinkX } = useXVerification();
const refreshFriends = useCallback(async () => {
await localRefreshFriends();
@@ -763,18 +765,32 @@ export function UserMenu({ events, itinerary, onOpenFriends, pendingIncomingCoun
-
-
-
{'\u{1D54F}'}
-
setXHandle(e.target.value.replace(/^@/, ''))}
- placeholder="X handle"
- className="flex-1 bg-transparent text-[var(--theme-text-primary)] text-sm px-2 py-2 focus:outline-none placeholder:text-[var(--theme-text-muted)]"
- />
+ {/* X Account */}
+ {profile?.x_verified ? (
+
+
+
+
+
@{profile.x_handle}
+
+
+ Disconnect
+
-
+ ) : (
+
+
+
+
+ Connect X Account
+
+ )}
diff --git a/src/components/CommentSection.tsx b/src/components/CommentSection.tsx
index 12f31b3..dd46096 100644
--- a/src/components/CommentSection.tsx
+++ b/src/components/CommentSection.tsx
@@ -6,11 +6,13 @@ import { createPortal } from 'react-dom';
import { Send, Trash2, MessageCircle, X } from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext';
import { useEventComments } from '@/hooks/useEventComments';
+import { supabase } from '@/lib/supabase';
import { timeAgo } from '@/lib/time-parse';
import { getDisplayName } from '@/lib/user-display';
import UserAvatar from './UserAvatar';
import ProfileCardModal from './ProfileCardModal';
import { trackCommentExpand, trackCommentAdd, trackCommentDelete, trackCommentVisibilityToggle } from '@/lib/analytics';
+import { useXVerification } from '@/hooks/useXVerification';
interface CommentSectionProps {
eventId: string;
@@ -20,6 +22,7 @@ interface CommentSectionProps {
export function CommentSection({ eventId, commentCount = 0, eventName }: CommentSectionProps) {
const { user } = useAuth();
+ const { isXVerified, linkX } = useXVerification();
const [expanded, setExpanded] = useState(false);
const isMobile = useMediaQuery('(max-width: 639px)');
const { comments, loading, addComment, deleteComment } = useEventComments(
@@ -27,6 +30,10 @@ export function CommentSection({ eventId, commentCount = 0, eventName }: Comment
);
const [text, setText] = useState('');
const [visibility, setVisibility] = useState<'public' | 'friends'>('public');
+ const [showEmailPrompt, setShowEmailPrompt] = useState(false);
+ const [email, setEmail] = useState('');
+ const [emailLoading, setEmailLoading] = useState(false);
+ const [emailError, setEmailError] = useState
(null);
const [selectedProfile, setSelectedProfile] = useState<{
userId: string;
displayName?: string | null;
@@ -155,8 +162,37 @@ export function CommentSection({ eventId, commentCount = 0, eventName }: Comment
>
);
+ // Handle email sign-up then X OAuth link
+ const handleEmailThenLinkX = async () => {
+ if (!email.trim()) return;
+ setEmailLoading(true);
+ setEmailError(null);
+
+ // Sign up with a random password — auto-confirms since enable_confirmations = false
+ // Next login they'll use OTP
+ const { error } = await supabase.auth.signUp({
+ email: email.trim(),
+ password: crypto.randomUUID(),
+ });
+
+ if (error) {
+ // If account exists, they need to sign in properly
+ if (error.message?.includes('already registered') || error.status === 422) {
+ setEmailError('Account exists — sign in from the menu first');
+ } else {
+ setEmailError(error.message);
+ }
+ setEmailLoading(false);
+ return;
+ }
+
+ // Account created + session active, now start X OAuth
+ setEmailLoading(false);
+ linkX();
+ };
+
// Shared input content
- const inputContent = user && (
+ const inputContent = user && isXVerified ? (
+ ) : user && !isXVerified ? (
+ // Logged in but X not verified — go straight to X OAuth
+
{
+ e.stopPropagation();
+ linkX();
+ }}
+ className="flex items-center justify-center gap-2 w-full py-2.5 px-3 rounded-lg border border-[var(--theme-border-primary)] bg-[var(--theme-bg-tertiary)]/50 text-sm text-[var(--theme-text-secondary)] hover:text-[var(--theme-text-primary)] hover:border-[var(--theme-accent)] transition-colors cursor-pointer"
+ >
+
+
+
+ Connect X to comment
+
+ ) : showEmailPrompt ? (
+ // Not logged in — email prompt before X OAuth
+
+
+ setEmail(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleEmailThenLinkX();
+ }
+ }}
+ placeholder="Enter your email..."
+ className="flex-1 bg-[var(--theme-bg-tertiary)]/50 border border-[var(--theme-border-primary)] rounded-lg px-3 py-2 text-sm text-[var(--theme-text-primary)] placeholder-slate-500 outline-none min-w-0"
+ autoFocus
+ />
+
+ {emailLoading ? '...' : 'Go'}
+
+
+ {emailError && (
+
{emailError}
+ )}
+
+ ) : (
+ // Not logged in — show connect CTA
+
{
+ e.stopPropagation();
+ setShowEmailPrompt(true);
+ }}
+ className="flex items-center justify-center gap-2 w-full py-2.5 px-3 rounded-lg border border-[var(--theme-border-primary)] bg-[var(--theme-bg-tertiary)]/50 text-sm text-[var(--theme-text-secondary)] hover:text-[var(--theme-text-primary)] hover:border-[var(--theme-accent)] transition-colors cursor-pointer"
+ >
+
+
+
+ Connect X to comment
+
);
// Mobile: render bottom-sheet modal via portal
@@ -249,11 +343,9 @@ export function CommentSection({ eventId, commentCount = 0, eventName }: Comment
{/* Sticky input at bottom */}
- {user && (
-
- {inputContent}
-
- )}
+
+ {inputContent}
+
,
document.body
diff --git a/src/components/EventCard.tsx b/src/components/EventCard.tsx
index 8f23880..10dbb14 100644
--- a/src/components/EventCard.tsx
+++ b/src/components/EventCard.tsx
@@ -18,6 +18,7 @@ import { EmojiReactions } from './EmojiReactions';
import UserAvatar from './UserAvatar';
import { FriendAvatarStack } from './FriendAvatarStack';
import { RsvpButton } from './RsvpButton';
+import { CommentSection } from './CommentSection';
interface EventCardProps {
event: ETHDenverEvent;
@@ -131,6 +132,7 @@ export const EventCard = memo(function EventCard({
checkInCount,
reactions,
onToggleReaction,
+ commentCount,
conference,
onCheckIn,
checkInLoading,
@@ -400,8 +402,7 @@ export const EventCard = memo(function EventCard({
compact={compact}
/>
)}
- {/* Comments disabled until social verification is in place */}
- {/*
*/}
+
{checkedInFriends && checkedInFriends.length > 0 && (
{
diff --git a/src/hooks/useEventComments.ts b/src/hooks/useEventComments.ts
index 26c5cbb..bc53d65 100644
--- a/src/hooks/useEventComments.ts
+++ b/src/hooks/useEventComments.ts
@@ -41,19 +41,19 @@ export function useEventComments(eventId: string | null) {
// Fetch display names for comment authors
const userIds = [...new Set((data ?? []).map((c: { user_id: string }) => c.user_id))];
- let profileMap = new Map();
+ let profileMap = new Map();
if (userIds.length > 0) {
const { data: profiles } = await supabase
.from('profiles')
- .select('user_id, display_name, x_handle, rsvp_name, avatar_url, job_title, company, linkedin_url, telegram_handle')
+ .select('user_id, display_name, x_handle, rsvp_name, avatar_url, job_title, company, linkedin_url, telegram_handle, x_verified')
.in('user_id', userIds);
if (profiles) {
profileMap = new Map(
- profiles.map((p: { user_id: string; display_name: string | null; x_handle: string | null; rsvp_name: string | null; avatar_url: string | null; job_title: string | null; company: string | null; linkedin_url: string | null; telegram_handle: string | null }) => [
+ profiles.map((p: { user_id: string; display_name: string | null; x_handle: string | null; rsvp_name: string | null; avatar_url: string | null; job_title: string | null; company: string | null; linkedin_url: string | null; telegram_handle: string | null; x_verified: boolean }) => [
p.user_id,
- { display_name: p.display_name, x_handle: p.x_handle, rsvp_name: p.rsvp_name, avatar_url: p.avatar_url, job_title: p.job_title, company: p.company, linkedin_url: p.linkedin_url, telegram_handle: p.telegram_handle },
+ { display_name: p.display_name, x_handle: p.x_handle, rsvp_name: p.rsvp_name, avatar_url: p.avatar_url, job_title: p.job_title, company: p.company, linkedin_url: p.linkedin_url, telegram_handle: p.telegram_handle, x_verified: p.x_verified ?? false },
])
);
}
@@ -73,6 +73,7 @@ export function useEventComments(eventId: string | null) {
company: profile?.company ?? undefined,
linkedin_url: profile?.linkedin_url ?? undefined,
telegram_handle: profile?.telegram_handle ?? undefined,
+ x_verified: profile?.x_verified ?? false,
};
})
);
@@ -106,7 +107,7 @@ export function useEventComments(eventId: string | null) {
// Fetch own profile for display
const { data: profile } = await supabase
.from('profiles')
- .select('display_name, x_handle, rsvp_name, avatar_url, job_title, company, linkedin_url, telegram_handle')
+ .select('display_name, x_handle, rsvp_name, avatar_url, job_title, company, linkedin_url, telegram_handle, x_verified')
.eq('user_id', user.id)
.single();
@@ -123,6 +124,7 @@ export function useEventComments(eventId: string | null) {
company: profile?.company ?? undefined,
linkedin_url: profile?.linkedin_url ?? undefined,
telegram_handle: profile?.telegram_handle ?? undefined,
+ x_verified: profile?.x_verified ?? false,
},
]);
}
diff --git a/src/hooks/useProfile.ts b/src/hooks/useProfile.ts
index bc1177c..de8ed5f 100644
--- a/src/hooks/useProfile.ts
+++ b/src/hooks/useProfile.ts
@@ -121,6 +121,9 @@ export function useProfile() {
company: null,
linkedin_url: null,
job_title: null,
+ x_verified: false,
+ x_oauth_id: null,
+ x_avatar_url: null,
};
const { error: insertError } = await supabase
diff --git a/src/hooks/useXVerification.ts b/src/hooks/useXVerification.ts
new file mode 100644
index 0000000..8a0f9ff
--- /dev/null
+++ b/src/hooks/useXVerification.ts
@@ -0,0 +1,105 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { useAuth } from '@/contexts/AuthContext';
+import { supabase } from '@/lib/supabase';
+
+export function useXVerification() {
+ const { user } = useAuth();
+ const [isXVerified, setIsXVerified] = useState(false);
+ const [xHandle, setXHandle] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ // Fetch verification status from profile
+ useEffect(() => {
+ async function checkVerification() {
+ if (!user) {
+ setIsXVerified(false);
+ setXHandle(null);
+ setLoading(false);
+ return;
+ }
+
+ const { data } = await supabase
+ .from('profiles')
+ .select('x_verified, x_handle, x_oauth_id')
+ .eq('user_id', user!.id)
+ .single();
+
+ if (data) {
+ setIsXVerified(data.x_verified ?? false);
+ setXHandle(data.x_handle ?? null);
+ }
+ setLoading(false);
+ }
+
+ checkVerification();
+ }, [user]);
+
+ // After OAuth redirect, detect newly linked X identity and update profile
+ useEffect(() => {
+ if (!user || isXVerified) return;
+
+ const twitterIdentity = user.identities?.find(
+ (id) => id.provider === 'twitter'
+ );
+
+ if (twitterIdentity) {
+ const handle = twitterIdentity.identity_data?.user_name;
+ const avatarUrl = twitterIdentity.identity_data?.avatar_url;
+ const oauthId = twitterIdentity.id;
+
+ if (handle && oauthId) {
+ // Update profile with verified X data
+ supabase
+ .from('profiles')
+ .update({
+ x_handle: handle,
+ x_verified: true,
+ x_oauth_id: oauthId,
+ x_avatar_url: avatarUrl || null,
+ })
+ .eq('user_id', user.id)
+ .then(() => {
+ setIsXVerified(true);
+ setXHandle(handle);
+ });
+ }
+ }
+ }, [user, isXVerified]);
+
+ const linkX = useCallback(async () => {
+ const { error } = await supabase.auth.linkIdentity({
+ provider: 'twitter',
+ options: {
+ redirectTo: window.location.origin + window.location.pathname,
+ },
+ });
+ if (error) console.error('Failed to link X:', error);
+ // Browser will redirect to X OAuth
+ }, []);
+
+ const unlinkX = useCallback(async () => {
+ if (!user) return;
+
+ const twitterIdentity = user.identities?.find(
+ (id) => id.provider === 'twitter'
+ );
+
+ if (twitterIdentity) {
+ await supabase.auth.unlinkIdentity(twitterIdentity);
+ await supabase
+ .from('profiles')
+ .update({
+ x_verified: false,
+ x_oauth_id: null,
+ x_avatar_url: null,
+ })
+ .eq('user_id', user.id);
+
+ setIsXVerified(false);
+ }
+ }, [user]);
+
+ return { isXVerified, xHandle, loading, linkX, unlinkX };
+}
diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts
index 8afcdab..1d90289 100644
--- a/src/lib/analytics.ts
+++ b/src/lib/analytics.ts
@@ -239,3 +239,8 @@ export const trackItineraryView = () =>
// Error tracking
export const trackError = (message: string, source?: string) =>
track('error', { message: message.slice(0, 100), ...(source ? { source } : {}) });
+
+// X (Twitter) verification
+export const trackXConnectStart = () => track('x_connect_start');
+export const trackXConnectSuccess = () => track('x_connect_success');
+export const trackXDisconnect = () => track('x_disconnect');
diff --git a/src/lib/profile-cache.ts b/src/lib/profile-cache.ts
index e8e90f5..451ca6b 100644
--- a/src/lib/profile-cache.ts
+++ b/src/lib/profile-cache.ts
@@ -7,6 +7,7 @@ export interface CachedProfile {
x_handle: string | null;
rsvp_name: string | null;
avatar_url: string | null;
+ x_verified: boolean;
}
const cache = new Map();
@@ -30,7 +31,7 @@ export async function fetchProfiles(
if (uncached.length > 0) {
const { data } = await client
.from('profiles')
- .select('user_id, email, display_name, x_handle, rsvp_name, avatar_url')
+ .select('user_id, email, display_name, x_handle, rsvp_name, avatar_url, x_verified')
.in('user_id', uncached);
for (const p of data ?? []) {
@@ -41,6 +42,7 @@ export async function fetchProfiles(
x_handle: p.x_handle ?? null,
rsvp_name: p.rsvp_name ?? null,
avatar_url: p.avatar_url ?? null,
+ x_verified: p.x_verified ?? false,
};
cache.set(p.user_id, profile);
result.set(p.user_id, profile);
diff --git a/src/lib/types.ts b/src/lib/types.ts
index fc8a077..93df446 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -66,6 +66,9 @@ export interface UserProfile {
company: string | null;
linkedin_url: string | null;
job_title: string | null;
+ x_verified: boolean;
+ x_oauth_id: string | null;
+ x_avatar_url: string | null;
/** Opt-in: share raw GPS with friends (default false). */
share_live_location?: boolean;
/** Opt-in: auto check in at live plan events while the app is open (default false). */
@@ -136,6 +139,7 @@ export interface EventComment {
company?: string;
linkedin_url?: string;
telegram_handle?: string;
+ x_verified?: boolean;
}
export interface FriendLocation {
diff --git a/supabase/migrations/20260505_add_x_verification.sql b/supabase/migrations/20260505_add_x_verification.sql
new file mode 100644
index 0000000..910ef10
--- /dev/null
+++ b/supabase/migrations/20260505_add_x_verification.sql
@@ -0,0 +1,5 @@
+ALTER TABLE profiles ADD COLUMN IF NOT EXISTS x_verified boolean NOT NULL DEFAULT false;
+ALTER TABLE profiles ADD COLUMN IF NOT EXISTS x_oauth_id text;
+ALTER TABLE profiles ADD COLUMN IF NOT EXISTS x_avatar_url text;
+
+CREATE INDEX IF NOT EXISTS idx_profiles_x_verified ON profiles(x_verified) WHERE x_verified = true;