updated contribution page to show special mentions

This commit is contained in:
tapframe 2025-12-20 19:36:54 +05:30
parent d9aaa045fd
commit 6e3f79a231
2 changed files with 445 additions and 88 deletions

4
.gitignore vendored
View file

@ -31,9 +31,9 @@ yarn-error.*
*.pem
# local env files
.env
.env*.local
.env*.local
.env
# Sentry
ios/sentry.properties
android/sentry.properties

View file

@ -12,13 +12,14 @@ import {
Linking,
RefreshControl,
FlatList,
ActivityIndicator
ActivityIndicator,
Alert
} from 'react-native';
import { mmkvStorage } from '../services/mmkvStorage';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import FastImage from '@d11/react-native-fast-image';
import { Feather } from '@expo/vector-icons';
import { Feather, FontAwesome5 } from '@expo/vector-icons';
import { useTheme } from '../contexts/ThemeContext';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { fetchContributors, GitHubContributor } from '../services/githubReleaseService';
@ -30,6 +31,48 @@ const isLargeTablet = width >= 1024;
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
// Discord API URL from environment
const DISCORD_USER_API = process.env.EXPO_PUBLIC_DISCORD_USER_API || 'https://pfpfinder.com/api/discord/user';
// Discord brand color
const DISCORD_BRAND_COLOR = '#5865F2';
// Special mentions - Discord community members (only store IDs and roles)
interface SpecialMentionConfig {
discordId: string;
role: string;
description: string;
}
interface DiscordUserData {
id: string;
global_name: string | null;
username: string;
avatar: string | null;
}
interface SpecialMention extends SpecialMentionConfig {
name: string;
username: string;
avatarUrl: string;
isLoading: boolean;
}
const SPECIAL_MENTIONS_CONFIG: SpecialMentionConfig[] = [
{
discordId: '709281623866081300',
role: 'Community Manager',
description: 'Manages the Discord & Reddit communities for Nuvio',
},
{
discordId: '777773947071758336',
role: 'Server Sponsor',
description: 'Sponsored the server infrastructure for Nuvio',
},
];
type TabType = 'contributors' | 'special';
interface ContributorCardProps {
contributor: GitHubContributor;
currentTheme: any;
@ -86,15 +129,174 @@ const ContributorCard: React.FC<ContributorCardProps> = ({ contributor, currentT
);
};
// Special Mention Card Component - Same layout as ContributorCard
interface SpecialMentionCardProps {
mention: SpecialMention;
currentTheme: any;
isTablet: boolean;
isLargeTablet: boolean;
}
const SpecialMentionCard: React.FC<SpecialMentionCardProps> = ({ mention, currentTheme, isTablet, isLargeTablet }) => {
const handlePress = useCallback(() => {
// Try to open Discord profile
const discordUrl = `discord://-/users/${mention.discordId}`;
Linking.canOpenURL(discordUrl).then((supported) => {
if (supported) {
Linking.openURL(discordUrl);
} else {
// Fallback: show alert with Discord info
Alert.alert(
mention.name,
`Discord: @${mention.username}\n\nOpen Discord and search for this user to connect with them.`,
[{ text: 'OK' }]
);
}
});
}, [mention.discordId, mention.name, mention.username]);
// Default avatar fallback
const defaultAvatar = `https://cdn.discordapp.com/embed/avatars/0.png`;
return (
<TouchableOpacity
style={[
styles.contributorCard,
{ backgroundColor: currentTheme.colors.elevation1 },
isTablet && styles.tabletContributorCard
]}
onPress={handlePress}
activeOpacity={0.7}
>
{/* Avatar with Discord badge */}
<View style={styles.specialAvatarContainer}>
{mention.isLoading ? (
<View style={[
styles.avatar,
isTablet && styles.tabletAvatar,
{ backgroundColor: currentTheme.colors.elevation2, justifyContent: 'center', alignItems: 'center' }
]}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
) : (
<FastImage
source={{ uri: mention.avatarUrl || defaultAvatar }}
style={[
styles.avatar,
isTablet && styles.tabletAvatar
]}
resizeMode={FastImage.resizeMode.cover}
/>
)}
<View style={[styles.discordBadgeSmall, { backgroundColor: DISCORD_BRAND_COLOR }]}>
<FontAwesome5 name="discord" size={10} color="#FFFFFF" />
</View>
</View>
{/* User info */}
<View style={styles.contributorInfo}>
<Text style={[
styles.username,
{ color: currentTheme.colors.highEmphasis },
isTablet && styles.tabletUsername
]}>
{mention.isLoading ? 'Loading...' : mention.name}
</Text>
{!mention.isLoading && mention.username && (
<Text style={[
styles.contributions,
{ color: currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletContributions
]}>
@{mention.username}
</Text>
)}
<View style={[styles.roleBadgeSmall, { backgroundColor: currentTheme.colors.primary + '20' }]}>
<Text style={[styles.roleBadgeText, { color: currentTheme.colors.primary }]}>
{mention.role}
</Text>
</View>
</View>
{/* Discord icon on right */}
<FontAwesome5
name="discord"
size={isTablet ? 20 : 16}
color={currentTheme.colors.mediumEmphasis}
style={styles.externalIcon}
/>
</TouchableOpacity>
);
};
const ContributorsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
const [activeTab, setActiveTab] = useState<TabType>('contributors');
const [contributors, setContributors] = useState<GitHubContributor[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [specialMentions, setSpecialMentions] = useState<SpecialMention[]>([]);
const [specialMentionsLoading, setSpecialMentionsLoading] = useState(true);
// Fetch Discord user data for special mentions
const loadSpecialMentions = useCallback(async () => {
setSpecialMentionsLoading(true);
// Initialize with loading state
const initialMentions: SpecialMention[] = SPECIAL_MENTIONS_CONFIG.map(config => ({
...config,
name: 'Loading...',
username: '',
avatarUrl: '',
isLoading: true,
}));
setSpecialMentions(initialMentions);
// Fetch each user's data from Discord API
const fetchedMentions = await Promise.all(
SPECIAL_MENTIONS_CONFIG.map(async (config): Promise<SpecialMention> => {
try {
const response = await fetch(`${DISCORD_USER_API}/${config.discordId}`);
if (!response.ok) {
throw new Error('Failed to fetch Discord user');
}
const userData: DiscordUserData = await response.json();
return {
...config,
name: userData.global_name || userData.username,
username: userData.username,
avatarUrl: userData.avatar || '',
isLoading: false,
};
} catch (error) {
if (__DEV__) console.error(`Error fetching Discord user ${config.discordId}:`, error);
// Return fallback data
return {
...config,
name: 'Discord User',
username: config.discordId,
avatarUrl: '',
isLoading: false,
};
}
})
);
setSpecialMentions(fetchedMentions);
setSpecialMentionsLoading(false);
}, []);
// Load special mentions when switching to that tab
useEffect(() => {
if (activeTab === 'special' && specialMentions.length === 0) {
loadSpecialMentions();
}
}, [activeTab, specialMentions.length, loadSpecialMentions]);
const loadContributors = useCallback(async (isRefresh = false) => {
try {
@ -104,7 +306,7 @@ const ContributorsScreen: React.FC = () => {
setLoading(true);
}
setError(null);
// Check cache first (unless refreshing)
if (!isRefresh) {
try {
@ -112,7 +314,7 @@ const ContributorsScreen: React.FC = () => {
const cacheTimestamp = await mmkvStorage.getItem('github_contributors_timestamp');
const now = Date.now();
const ONE_HOUR = 60 * 60 * 1000; // 1 hour cache
if (cachedData && cacheTimestamp) {
const timestamp = parseInt(cacheTimestamp, 10);
if (now - timestamp < ONE_HOUR) {
@ -136,10 +338,10 @@ const ContributorsScreen: React.FC = () => {
try {
await mmkvStorage.removeItem('github_contributors');
await mmkvStorage.removeItem('github_contributors_timestamp');
} catch {}
} catch { }
}
}
const data = await fetchContributors();
if (data && Array.isArray(data) && data.length > 0) {
setContributors(data);
@ -155,7 +357,7 @@ const ContributorsScreen: React.FC = () => {
try {
await mmkvStorage.removeItem('github_contributors');
await mmkvStorage.removeItem('github_contributors_timestamp');
} catch {}
} catch { }
setError('Unable to load contributors. This might be due to GitHub API rate limits.');
}
} catch (err) {
@ -184,7 +386,7 @@ const ContributorsScreen: React.FC = () => {
if (__DEV__) console.error('Error checking cache on mount:', error);
}
};
clearInvalidCache();
loadContributors();
}, [loadContributors]);
@ -247,7 +449,7 @@ const ContributorsScreen: React.FC = () => {
{ backgroundColor: currentTheme.colors.darkBackground }
]}>
<StatusBar barStyle={'light-content'} />
<View style={[styles.headerContainer, { paddingTop: topSpacing }]}>
<View style={styles.header}>
<TouchableOpacity
@ -267,85 +469,176 @@ const ContributorsScreen: React.FC = () => {
</Text>
</View>
{/* Tab Switcher */}
<View style={[
styles.tabSwitcher,
{ backgroundColor: currentTheme.colors.elevation1 },
isTablet && styles.tabletTabSwitcher
]}>
<TouchableOpacity
style={[
styles.tab,
activeTab === 'contributors' && { backgroundColor: currentTheme.colors.primary },
isTablet && styles.tabletTab
]}
onPress={() => setActiveTab('contributors')}
activeOpacity={0.7}
>
<Text style={[
styles.tabText,
{ color: activeTab === 'contributors' ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletTabText
]}>
Contributors
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.tab,
activeTab === 'special' && { backgroundColor: currentTheme.colors.primary },
isTablet && styles.tabletTab
]}
onPress={() => setActiveTab('special')}
activeOpacity={0.7}
>
<Text style={[
styles.tabText,
{ color: activeTab === 'special' ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletTabText
]}>
Special Mentions
</Text>
</TouchableOpacity>
</View>
<View style={styles.content}>
<View style={[styles.contentContainer, isTablet && styles.tabletContentContainer]}>
{error ? (
<View style={styles.errorContainer}>
<Feather name="alert-circle" size={48} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.errorText, { color: currentTheme.colors.mediumEmphasis }]}>
{error}
</Text>
<Text style={[styles.errorSubtext, { color: currentTheme.colors.mediumEmphasis }]}>
GitHub API rate limit exceeded. Please try again later or pull to refresh.
</Text>
<TouchableOpacity
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => loadContributors()}
>
<Text style={[styles.retryText, { color: currentTheme.colors.white }]}>
Try Again
</Text>
</TouchableOpacity>
</View>
) : contributors.length === 0 ? (
<View style={styles.emptyContainer}>
<Feather name="users" size={48} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}>
No contributors found
</Text>
</View>
) : (
<ScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.listContent,
isTablet && styles.tabletListContent
]}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={currentTheme.colors.primary}
colors={[currentTheme.colors.primary]}
/>
}
showsVerticalScrollIndicator={false}
>
<View style={[
styles.gratitudeCard,
{ backgroundColor: currentTheme.colors.elevation1 },
isTablet && styles.tabletGratitudeCard
]}>
<View style={styles.gratitudeContent}>
<Feather name="heart" size={isTablet ? 32 : 24} color={currentTheme.colors.primary} />
<Text style={[
styles.gratitudeText,
{ color: currentTheme.colors.highEmphasis },
isTablet && styles.tabletGratitudeText
]}>
We're grateful for every contribution
</Text>
<Text style={[
styles.gratitudeSubtext,
{ color: currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletGratitudeSubtext
]}>
Each line of code, bug report, and suggestion helps make Nuvio better for everyone
</Text>
</View>
</View>
<FlatList
data={contributors}
renderItem={renderContributor}
keyExtractor={keyExtractor}
numColumns={isTablet ? 2 : 1}
key={isTablet ? 'tablet' : 'mobile'}
scrollEnabled={false}
{activeTab === 'contributors' ? (
// Contributors Tab
<>
{error ? (
<View style={styles.errorContainer}>
<Feather name="alert-circle" size={48} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.errorText, { color: currentTheme.colors.mediumEmphasis }]}>
{error}
</Text>
<Text style={[styles.errorSubtext, { color: currentTheme.colors.mediumEmphasis }]}>
GitHub API rate limit exceeded. Please try again later or pull to refresh.
</Text>
<TouchableOpacity
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => loadContributors()}
>
<Text style={[styles.retryText, { color: currentTheme.colors.white }]}>
Try Again
</Text>
</TouchableOpacity>
</View>
) : contributors.length === 0 ? (
<View style={styles.emptyContainer}>
<Feather name="users" size={48} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}>
No contributors found
</Text>
</View>
) : (
<ScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.listContent,
isTablet && styles.tabletListContent
]}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={currentTheme.colors.primary}
colors={[currentTheme.colors.primary]}
/>
}
showsVerticalScrollIndicator={false}
>
<View style={[
styles.gratitudeCard,
{ backgroundColor: currentTheme.colors.elevation1 },
isTablet && styles.tabletGratitudeCard
]}>
<View style={styles.gratitudeContent}>
<Feather name="heart" size={isTablet ? 32 : 24} color={currentTheme.colors.primary} />
<Text style={[
styles.gratitudeText,
{ color: currentTheme.colors.highEmphasis },
isTablet && styles.tabletGratitudeText
]}>
We're grateful for every contribution
</Text>
<Text style={[
styles.gratitudeSubtext,
{ color: currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletGratitudeSubtext
]}>
Each line of code, bug report, and suggestion helps make Nuvio better for everyone
</Text>
</View>
</View>
<FlatList
data={contributors}
renderItem={renderContributor}
keyExtractor={keyExtractor}
numColumns={isTablet ? 2 : 1}
key={isTablet ? 'tablet' : 'mobile'}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
columnWrapperStyle={isTablet ? styles.tabletRow : undefined}
/>
</ScrollView>
)}
</>
) : (
// Special Mentions Tab
<ScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.listContent,
isTablet && styles.tabletListContent
]}
showsVerticalScrollIndicator={false}
columnWrapperStyle={isTablet ? styles.tabletRow : undefined}
/>
</ScrollView>
>
<View style={[
styles.gratitudeCard,
{ backgroundColor: currentTheme.colors.elevation1 },
isTablet && styles.tabletGratitudeCard
]}>
<View style={styles.gratitudeContent}>
<FontAwesome5 name="star" size={isTablet ? 32 : 24} color={currentTheme.colors.primary} solid />
<Text style={[
styles.gratitudeText,
{ color: currentTheme.colors.highEmphasis },
isTablet && styles.tabletGratitudeText
]}>
Special Thanks
</Text>
<Text style={[
styles.gratitudeSubtext,
{ color: currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletGratitudeSubtext
]}>
These amazing people help keep the Nuvio community running and the servers online
</Text>
</View>
</View>
{specialMentions.map((mention: SpecialMention) => (
<SpecialMentionCard
key={mention.discordId}
mention={mention}
currentTheme={currentTheme}
isTablet={isTablet}
isLargeTablet={isLargeTablet}
/>
))}
</ScrollView>
)}
</View>
</View>
@ -563,6 +856,70 @@ const styles = StyleSheet.create({
externalIcon: {
marginLeft: 8,
},
// Special Mentions - Compact styles for horizontal layout
specialAvatarContainer: {
position: 'relative',
marginRight: 16,
},
discordBadgeSmall: {
position: 'absolute',
bottom: -2,
right: -2,
width: 20,
height: 20,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: '#1a1a1a',
},
roleBadgeSmall: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
marginTop: 4,
alignSelf: 'flex-start',
},
roleBadgeText: {
fontSize: 10,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.3,
},
// Tab Switcher Styles
tabSwitcher: {
flexDirection: 'row',
marginHorizontal: 16,
marginBottom: 16,
padding: 4,
borderRadius: 12,
},
tabletTabSwitcher: {
marginHorizontal: 32,
marginBottom: 24,
padding: 6,
borderRadius: 16,
},
tab: {
flex: 1,
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
},
tabletTab: {
paddingVertical: 14,
paddingHorizontal: 24,
borderRadius: 12,
},
tabText: {
fontSize: 14,
fontWeight: '600',
},
tabletTabText: {
fontSize: 16,
},
});
export default ContributorsScreen;