From 98c3fd5b53ed997414164b2903948e26a151a0bc Mon Sep 17 00:00:00 2001 From: FrankBStack <294698533+FrankBStack@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:07:26 -0400 Subject: [PATCH] Add public profiles and bios Tap a username or avatar on a post/comment that was posted with a username to open that user's profile (icon, name, bio, public posts). Own posts go to MyProfile. Bio field on Edit Profile, bio shown on MyProfile. Also guarded getKarmaInfo against groups missing from the group list. Closes #84 --- src/App.jsx | 2 + src/components/Comment.jsx | 31 +++++- src/components/Post.jsx | 39 ++++++-- src/screens/EditProfileScreen.jsx | 39 +++++++- src/screens/MyProfileScreen.jsx | 21 +++- src/screens/UserProfileScreen.jsx | 156 ++++++++++++++++++++++++++++++ 6 files changed, 271 insertions(+), 17 deletions(-) create mode 100644 src/screens/UserProfileScreen.jsx diff --git a/src/App.jsx b/src/App.jsx index 414e729..936e3b8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -16,6 +16,7 @@ import EditProfileScreen from './screens/EditProfileScreen'; import WriterScreen from './screens/WriterScreen'; import MessageScreen from './screens/MessagesScreen'; import ThreadScreen from './screens/ThreadScreen'; +import UserProfileScreen from './screens/UserProfileScreen'; import { storage, hasMigratedFromAsyncStorage, migrateFromAsyncStorage } from './utils/mmkv'; import { useMMKVBoolean, useMMKVString } from 'react-native-mmkv'; @@ -101,6 +102,7 @@ export default function App() { + diff --git a/src/components/Comment.jsx b/src/components/Comment.jsx index f353a99..c5dea77 100644 --- a/src/components/Comment.jsx +++ b/src/components/Comment.jsx @@ -1,6 +1,6 @@ import '../types/OffsidesTypes.js'; import React from 'react'; -import { View, Alert, Linking } from 'react-native'; +import { View, Alert, Linking, Pressable } from 'react-native'; import { Badge, Button, Card, Chip, IconButton, Text, useTheme } from 'react-native-paper'; import { setStringAsync as copyToClipboard } from 'expo-clipboard'; import timesago from 'timesago'; @@ -38,6 +38,20 @@ function Comment({ comment, nav, isolated = false }) { }); }; + const canOpenProfile = + !!nav && + !!comment?.identity?.posted_with_username && + !!comment?.identity?.name && + comment.identity.name != 'Anonymous'; + const openProfile = () => { + if (!canOpenProfile) return; + if (comment.authored_by_user) { + nav.push('MyProfile'); + } else { + nav.push('UserProfile', { username: comment.identity.name }); + } + }; + const deleteComment = () => { Alert.alert('Are you sure?', 'This will permanently delete this comment.', [ { @@ -68,7 +82,11 @@ function Comment({ comment, nav, isolated = false }) { mode="contained"> - + YOU )} - + + onPress={canOpenProfile ? openProfile : undefined} + style={{ + marginLeft: 10, + opacity: 0.75, + color: canOpenProfile ? colors.primary : undefined, + }}> @{comment.identity.name} )} diff --git a/src/components/Post.jsx b/src/components/Post.jsx index f0295e9..2d9f2a0 100644 --- a/src/components/Post.jsx +++ b/src/components/Post.jsx @@ -1,6 +1,6 @@ import { SidechatPostOrComment } from 'sidechat.js/src/types/SidechatTypes.js'; import React, { useState } from 'react'; -import { Alert, Linking, View } from 'react-native'; +import { Alert, Linking, Pressable, View } from 'react-native'; import { Card, Chip, IconButton, Text, useTheme } from 'react-native-paper'; import { setStringAsync as copyToClipboard } from 'expo-clipboard'; import timesago from 'timesago'; @@ -26,6 +26,7 @@ function Post({ cardMode = repost ? 'outlined' : 'elevated', apiInstance = null, themeColors = {}, + profileLink = true, }) { const colors = themeColors; const API = apiInstance; @@ -39,6 +40,21 @@ function Post({ const [identity, setIdentity] = useRecyclingState(post?.identity, [post]); const postID = post.id; + // Posts made with a username can be tapped through to that user's public profile. + const hasUsername = + !!identity?.name && + identity.name != 'Anonymous' && + identity.posted_with_username !== false; + const canOpenProfile = profileLink && hasUsername && !!nav; + const openProfile = React.useCallback(() => { + if (!canOpenProfile) return; + if (post.authored_by_user) { + nav.push('MyProfile'); + } else { + nav.push('UserProfile', { username: identity.name }); + } + }, [canOpenProfile, post.authored_by_user, identity?.name, nav]); + const upvote = React.useCallback(() => { const action = vote == 'upvote' ? 'none' : 'upvote'; API.setVote(postID, action).then(res => { @@ -100,12 +116,14 @@ function Post({ style={repost ? { marginBottom: 10 } : {}}> - + + + + onPress={canOpenProfile ? openProfile : undefined} + style={{ + marginLeft: 10, + opacity: 0.75, + color: canOpenProfile ? colors.primary : undefined, + }}> @{post.identity.name} )} diff --git a/src/screens/EditProfileScreen.jsx b/src/screens/EditProfileScreen.jsx index 3345a29..19793a0 100644 --- a/src/screens/EditProfileScreen.jsx +++ b/src/screens/EditProfileScreen.jsx @@ -32,6 +32,8 @@ function EditProfileScreen({ navigation }) { secondary: null, }); const [username, setUsername] = React.useState(''); + const [bio, setBio] = React.useState(''); + const [initialBio, setInitialBio] = React.useState(''); const [usernameError, setUsernameError] = React.useState(false); const [error, setError] = React.useState(false); const [emoji, setEmoji] = React.useState(); @@ -64,11 +66,33 @@ function EditProfileScreen({ navigation }) { if (u.user?.username) { setUsername(u.user.username); } + // The bio lives on the public profile object; fall back to any field on the user object. + let currentBio = ''; + if (typeof u.user?.bio === 'string') currentBio = u.user.bio; + else if (typeof u.user?.description === 'string') currentBio = u.user.description; + else if (u.user?.username) { + try { + const p = await API.getUserProfile(u.user.username); + if (typeof p?.description === 'string') currentBio = p.description; + } catch (e) { + // no public profile yet; leave the bio empty + } + } + setBio(currentBio); + setInitialBio(currentBio); setLoading(false); }; const saveIcon = async () => { const uname = await API.setUsername(appState.userID, username); await uname; + if (bio.trim() !== initialBio.trim()) { + const bioRes = await API.setUserBio(appState.userID, bio.trim()); + if (bioRes?.message) { + setError(true); + loadCurrent(); + return; + } + } const res = await API.setUserIcon( appState.userID, emoji, @@ -113,6 +137,19 @@ function EditProfileScreen({ navigation }) { You can't set that as your username. + + + {bio.length}/200 + )} setError(false)}> - Sorry, you can't set that as your icon. + Sorry, that couldn't be saved. ); diff --git a/src/screens/MyProfileScreen.jsx b/src/screens/MyProfileScreen.jsx index 3336491..3cb4669 100644 --- a/src/screens/MyProfileScreen.jsx +++ b/src/screens/MyProfileScreen.jsx @@ -48,11 +48,11 @@ function MyProfileScreen({ navigation }) { header: "Comment Karma", value: karmaObj?.comment || 0 }); - karmaObj?.groups.forEach(group => { - const g = groupList.find((item => item.id == group.group_id)); + (karmaObj?.groups || []).forEach(group => { + const g = (groupList || []).find((item => item.id == group.group_id)); karmaObjects.push({ - header: g.name, - value: group.post + group.comment + header: g?.name || 'Group', + value: (group.post || 0) + (group.comment || 0) }) }); return karmaObjects; @@ -67,6 +67,13 @@ function MyProfileScreen({ navigation }) { crashlytics().log('Fetching profile'); const u = await API.getUpdates(currentGroup?.id); crashlytics().log('Profile fetched successfully'); + // Pull the bio from the public profile if the user object doesn't carry one. + if (u?.user?.username && typeof u.user.bio !== 'string' && typeof u.user.description !== 'string') { + try { + const p = await API.getUserProfile(u.user.username); + if (typeof p?.description === 'string') u.user.bio = p.description; + } catch (e) { /* no public profile */ } + } setUpdates(u); setLoading(false); }; @@ -160,6 +167,12 @@ function MyProfileScreen({ navigation }) { + {(() => { + const b = typeof updates.user?.bio === 'string' ? updates.user.bio : typeof updates.user?.description === 'string' ? updates.user.description : ''; + return b.trim() ? ( + {b.trim()} + ) : null; + })()} {karmaInfo.map((item) => diff --git a/src/screens/UserProfileScreen.jsx b/src/screens/UserProfileScreen.jsx new file mode 100644 index 0000000..39ac368 --- /dev/null +++ b/src/screens/UserProfileScreen.jsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { View, StatusBar, FlatList } from 'react-native'; +import { + Appbar, + useTheme, + Text, + Avatar, + ProgressBar, + IconButton, +} from 'react-native-paper'; +import crashlytics from '@react-native-firebase/crashlytics'; +import { AppContext } from '../App'; +import Post from '../components/Post'; + +const BORDER_RADIUS = 15; + +/** + * Public profile of another user, looked up by username. + * Route params: { username: string } + */ +function UserProfileScreen({ navigation, route }) { + const username = route?.params?.username; + const { appState } = React.useContext(AppContext); + const API = appState.API; + const { colors } = useTheme(); + const [profile, setProfile] = React.useState(null); + const [posts, setPosts] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [unavailable, setUnavailable] = React.useState(false); + + React.useEffect(() => { + let cancelled = false; + const load = async () => { + crashlytics().log(`Loading UserProfileScreen for @${username}`); + const [p, ps] = await Promise.allSettled([ + API.getUserProfile(username), + API.getUserPosts(username), + ]); + if (cancelled) return; + if (p.status === 'fulfilled' && p.value && typeof p.value === 'object') { + setProfile(p.value); + } else { + setUnavailable(true); + } + if (ps.status === 'fulfilled' && Array.isArray(ps.value)) { + setPosts(ps.value.filter(i => i?.id)); + } else { + setPosts([]); + } + setLoading(false); + }; + if (username) { + load(); + } else { + setUnavailable(true); + setLoading(false); + } + return () => { + cancelled = true; + }; + }, [username]); + + const icon = profile?.conversation_icon; + const bio = + typeof profile?.description === 'string' && profile.description.trim() + ? profile.description.trim() + : typeof profile?.bio === 'string' && profile.bio.trim() + ? profile.bio.trim() + : null; + + const Header = ( + + + {icon?.emoji ? ( + + ) : ( + + )} + + @{profile?.name || username} + {posts && ( + + {posts.length} {posts.length === 1 ? 'post' : 'posts'} + + )} + + + {bio && ( + + {bio} + + )} + {unavailable && !profile && ( + + This profile isn't available. The user may have changed their + username or made their profile private. + + )} + + ); + + const Empty = !loading ? ( + + + + No public posts + + + ) : null; + + return ( + + + + navigation.goBack()} /> + + + {loading && } + item.id} + ListHeaderComponent={Header} + ListEmptyComponent={Empty} + contentContainerStyle={{ paddingBottom: 20 }} + ItemSeparatorComponent={() => } + renderItem={({ item }) => ( + + + + )} + ListHeaderComponentStyle={{ marginBottom: 10 }} + windowSize={10} + /> + + ); +} + +export default UserProfileScreen;