Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

[APP-522] Create & revoke App Passwords within settings (#505)

* create and delete app passwords

* add randomly generated name

* Tweak copy and layout of app passwords

* Improve app passwords on desktop web

* Rearrange settings

* Change app-passwords route and add to backend

* Fix link

* Fix some more desktop web

* Remove log

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

authored by

Ansh
Paul Frazee
and committed by
GitHub
38eb2990 aa56f4a5

+607 -8
+1
bskyweb/cmd/bskyweb/server.go
··· 92 92 e.GET("/search", server.WebGeneric) 93 93 e.GET("/notifications", server.WebGeneric) 94 94 e.GET("/settings", server.WebGeneric) 95 + e.GET("/settings/app-passwords", server.WebGeneric) 95 96 e.GET("/sys/debug", server.WebGeneric) 96 97 e.GET("/sys/log", server.WebGeneric) 97 98 e.GET("/support", server.WebGeneric)
+2
src/Navigation.tsx
··· 46 46 import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy' 47 47 import {usePalette} from 'lib/hooks/usePalette' 48 48 import {useStores} from './state' 49 + import {AppPasswords} from 'view/screens/AppPasswords' 49 50 50 51 const navigationRef = createNavigationContainerRef<AllNavigatorParams>() 51 52 ··· 84 85 component={CommunityGuidelinesScreen} 85 86 /> 86 87 <Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} /> 88 + <Stack.Screen name="AppPasswords" component={AppPasswords} /> 87 89 </> 88 90 ) 89 91 }
+1
src/lib/routes/types.ts
··· 19 19 TermsOfService: undefined 20 20 CommunityGuidelines: undefined 21 21 CopyrightPolicy: undefined 22 + AppPasswords: undefined 22 23 } 23 24 24 25 export type BottomTabNavigatorParams = CommonNavigatorParams & {
+1
src/routes.ts
··· 13 13 PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', 14 14 Debug: '/sys/debug', 15 15 Log: '/sys/log', 16 + AppPasswords: '/settings/app-passwords', 16 17 Support: '/support', 17 18 PrivacyPolicy: '/support/privacy', 18 19 TermsOfService: '/support/tos',
+59 -2
src/state/models/me.ts
··· 1 1 import {makeAutoObservable, runInAction} from 'mobx' 2 - import {ComAtprotoServerDefs} from '@atproto/api' 2 + import { 3 + ComAtprotoServerDefs, 4 + ComAtprotoServerListAppPasswords, 5 + } from '@atproto/api' 3 6 import {RootStoreModel} from './root-store' 4 7 import {PostsFeedModel} from './feeds/posts' 5 8 import {NotificationsFeedModel} from './feeds/notifications' ··· 21 24 notifications: NotificationsFeedModel 22 25 follows: MyFollowsCache 23 26 invites: ComAtprotoServerDefs.InviteCode[] = [] 27 + appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = [] 24 28 lastProfileStateUpdate = Date.now() 25 29 lastNotifsUpdate = Date.now() 26 30 ··· 37 41 this.mainFeed = new PostsFeedModel(this.rootStore, 'home', { 38 42 algorithm: 'reverse-chronological', 39 43 }) 40 - this.notifications = new NotificationsFeedModel(this.rootStore, {}) 44 + this.notifications = new NotificationsFeedModel(this.rootStore) 41 45 this.follows = new MyFollowsCache(this.rootStore) 42 46 } 43 47 ··· 51 55 this.description = '' 52 56 this.avatar = '' 53 57 this.invites = [] 58 + this.appPasswords = [] 54 59 } 55 60 56 61 serialize(): unknown { ··· 107 112 }) 108 113 this.rootStore.emitSessionLoaded() 109 114 await this.fetchInviteCodes() 115 + await this.fetchAppPasswords() 110 116 } else { 111 117 this.clear() 112 118 } ··· 118 124 this.lastProfileStateUpdate = Date.now() 119 125 await this.fetchProfile() 120 126 await this.fetchInviteCodes() 127 + await this.fetchAppPasswords() 121 128 } 122 129 if (Date.now() - this.lastNotifsUpdate > NOTIFS_UPDATE_INTERVAL) { 123 130 this.lastNotifsUpdate = Date.now() ··· 169 176 this.rootStore.log.error('Failed to fetch user invite codes', e) 170 177 } 171 178 await this.rootStore.invitedUsers.fetch(this.invites) 179 + } 180 + } 181 + 182 + async fetchAppPasswords() { 183 + if (this.rootStore.session) { 184 + try { 185 + const res = 186 + await this.rootStore.agent.com.atproto.server.listAppPasswords({}) 187 + runInAction(() => { 188 + this.appPasswords = res.data.passwords 189 + }) 190 + } catch (e) { 191 + this.rootStore.log.error('Failed to fetch user app passwords', e) 192 + } 193 + } 194 + } 195 + 196 + async createAppPassword(name: string) { 197 + if (this.rootStore.session) { 198 + try { 199 + if (this.appPasswords.find(p => p.name === name)) { 200 + // TODO: this should be handled by the backend but it's not 201 + throw new Error('App password with this name already exists') 202 + } 203 + const res = 204 + await this.rootStore.agent.com.atproto.server.createAppPassword({ 205 + name, 206 + }) 207 + runInAction(() => { 208 + this.appPasswords.push(res.data) 209 + }) 210 + return res.data 211 + } catch (e) { 212 + this.rootStore.log.error('Failed to create app password', e) 213 + } 214 + } 215 + } 216 + 217 + async deleteAppPassword(name: string) { 218 + if (this.rootStore.session) { 219 + try { 220 + await this.rootStore.agent.com.atproto.server.revokeAppPassword({ 221 + name: name, 222 + }) 223 + runInAction(() => { 224 + this.appPasswords = this.appPasswords.filter(p => p.name !== name) 225 + }) 226 + } catch (e) { 227 + this.rootStore.log.error('Failed to delete app password', e) 228 + } 172 229 } 173 230 } 174 231 }
+5
src/state/models/ui/shell.ts
··· 70 70 name: 'invite-codes' 71 71 } 72 72 73 + export interface AddAppPasswordModal { 74 + name: 'add-app-password' 75 + } 76 + 73 77 export interface ContentFilteringSettingsModal { 74 78 name: 'content-filtering-settings' 75 79 } ··· 79 83 | ChangeHandleModal 80 84 | DeleteAccountModal 81 85 | EditProfileModal 86 + | AddAppPasswordModal 82 87 83 88 // Curation 84 89 | ContentFilteringSettingsModal
+216
src/view/com/modals/AddAppPasswords.tsx
··· 1 + import React, {useState} from 'react' 2 + import {StyleSheet, TextInput, View, TouchableOpacity} from 'react-native' 3 + import {Text} from '../util/text/Text' 4 + import {Button} from '../util/forms/Button' 5 + import {s} from 'lib/styles' 6 + import {useStores} from 'state/index' 7 + import {usePalette} from 'lib/hooks/usePalette' 8 + import {isDesktopWeb} from 'platform/detection' 9 + import { 10 + FontAwesomeIcon, 11 + FontAwesomeIconStyle, 12 + } from '@fortawesome/react-native-fontawesome' 13 + import Clipboard from '@react-native-clipboard/clipboard' 14 + import * as Toast from '../util/Toast' 15 + 16 + export const snapPoints = ['70%'] 17 + 18 + const shadesOfBlue: string[] = [ 19 + 'AliceBlue', 20 + 'Aqua', 21 + 'Aquamarine', 22 + 'Azure', 23 + 'BabyBlue', 24 + 'Blue', 25 + 'BlueViolet', 26 + 'CadetBlue', 27 + 'CornflowerBlue', 28 + 'Cyan', 29 + 'DarkBlue', 30 + 'DarkCyan', 31 + 'DarkSlateBlue', 32 + 'DeepSkyBlue', 33 + 'DodgerBlue', 34 + 'ElectricBlue', 35 + 'LightBlue', 36 + 'LightCyan', 37 + 'LightSkyBlue', 38 + 'LightSteelBlue', 39 + 'MediumAquaMarine', 40 + 'MediumBlue', 41 + 'MediumSlateBlue', 42 + 'MidnightBlue', 43 + 'Navy', 44 + 'PowderBlue', 45 + 'RoyalBlue', 46 + 'SkyBlue', 47 + 'SlateBlue', 48 + 'SteelBlue', 49 + 'Teal', 50 + 'Turquoise', 51 + ] 52 + 53 + export function Component({}: {}) { 54 + const pal = usePalette('default') 55 + const store = useStores() 56 + const [name, setName] = useState( 57 + shadesOfBlue[Math.floor(Math.random() * shadesOfBlue.length)], 58 + ) 59 + const [appPassword, setAppPassword] = useState<string>() 60 + const [wasCopied, setWasCopied] = useState(false) 61 + 62 + const onCopy = React.useCallback(() => { 63 + if (appPassword) { 64 + Clipboard.setString(appPassword) 65 + Toast.show('Copied to clipboard') 66 + setWasCopied(true) 67 + } 68 + }, [appPassword]) 69 + 70 + const onDone = React.useCallback(() => { 71 + store.shell.closeModal() 72 + }, [store]) 73 + 74 + const createAppPassword = async () => { 75 + try { 76 + const newPassword = await store.me.createAppPassword(name) 77 + if (newPassword) { 78 + setAppPassword(newPassword.password) 79 + } else { 80 + Toast.show('Failed to create app password.') 81 + // TODO: better error handling (?) 82 + } 83 + } catch (e) { 84 + Toast.show('Failed to create app password.') 85 + store.log.error('Failed to create app password', {e}) 86 + } 87 + } 88 + 89 + return ( 90 + <View style={[styles.container, pal.view]} testID="addAppPasswordsModal"> 91 + <View> 92 + {!appPassword ? ( 93 + <Text type="lg"> 94 + Please enter a unique name for this App Password. We have generated 95 + a random name for you. 96 + </Text> 97 + ) : ( 98 + <Text type="lg"> 99 + <Text type="lg-bold">Here is your app password.</Text> Use this to 100 + sign into the other app along with your handle. 101 + </Text> 102 + )} 103 + {!appPassword ? ( 104 + <View style={[pal.btn, styles.textInputWrapper]}> 105 + <TextInput 106 + style={[styles.input, pal.text]} 107 + onChangeText={setName} 108 + value={name} 109 + placeholder="Enter a name for this App Password" 110 + placeholderTextColor={pal.colors.textLight} 111 + autoCorrect={false} 112 + autoComplete="off" 113 + autoCapitalize="none" 114 + autoFocus={true} 115 + selectTextOnFocus={true} 116 + multiline={true} // need this to be true otherwise selectTextOnFocus doesn't work 117 + numberOfLines={1} // hack for multiline so only one line shows (android) 118 + scrollEnabled={false} // hack for multiline so only one line shows (ios) 119 + blurOnSubmit={true} // hack for multiline so it submits 120 + editable={!appPassword} 121 + returnKeyType="done" 122 + onEndEditing={createAppPassword} 123 + /> 124 + </View> 125 + ) : ( 126 + <TouchableOpacity 127 + style={[pal.border, styles.passwordContainer, pal.btn]} 128 + onPress={onCopy}> 129 + <Text type="2xl-bold">{appPassword}</Text> 130 + {wasCopied ? ( 131 + <Text style={[pal.textLight]}>Copied</Text> 132 + ) : ( 133 + <FontAwesomeIcon 134 + icon={['far', 'clone']} 135 + style={pal.text as FontAwesomeIconStyle} 136 + size={18} 137 + /> 138 + )} 139 + </TouchableOpacity> 140 + )} 141 + </View> 142 + {appPassword ? ( 143 + <Text type="lg" style={[pal.textLight, s.mb10]}> 144 + For security reasons, you won't be able to view this again. If you 145 + lose this password, you'll need to generate a new one. 146 + </Text> 147 + ) : null} 148 + <View style={styles.btnContainer}> 149 + <Button 150 + type="primary" 151 + label={!appPassword ? 'Create App Password' : 'Done'} 152 + style={styles.btn} 153 + labelStyle={styles.btnLabel} 154 + onPress={!appPassword ? createAppPassword : onDone} 155 + /> 156 + </View> 157 + </View> 158 + ) 159 + } 160 + 161 + const styles = StyleSheet.create({ 162 + container: { 163 + flex: 1, 164 + paddingBottom: isDesktopWeb ? 0 : 50, 165 + marginHorizontal: 16, 166 + }, 167 + textInputWrapper: { 168 + borderRadius: 8, 169 + flexDirection: 'row', 170 + alignItems: 'center', 171 + marginTop: 16, 172 + marginBottom: 8, 173 + }, 174 + input: { 175 + flex: 1, 176 + width: '100%', 177 + paddingVertical: 10, 178 + paddingHorizontal: 8, 179 + marginTop: 6, 180 + fontSize: 17, 181 + letterSpacing: 0.25, 182 + fontWeight: '400', 183 + borderRadius: 10, 184 + }, 185 + passwordContainer: { 186 + flexDirection: 'row', 187 + justifyContent: 'space-between', 188 + paddingVertical: 8, 189 + paddingHorizontal: 16, 190 + alignItems: 'center', 191 + borderRadius: 10, 192 + marginTop: 16, 193 + marginBottom: 12, 194 + }, 195 + btnContainer: { 196 + flexDirection: 'row', 197 + justifyContent: 'center', 198 + marginTop: 12, 199 + }, 200 + btn: { 201 + flexDirection: 'row', 202 + alignItems: 'center', 203 + justifyContent: 'center', 204 + borderRadius: 32, 205 + paddingHorizontal: 60, 206 + paddingVertical: 14, 207 + }, 208 + btnLabel: { 209 + fontSize: 18, 210 + }, 211 + groupContent: { 212 + borderTopWidth: 1, 213 + flexDirection: 'row', 214 + alignItems: 'center', 215 + }, 216 + })
+4
src/view/com/modals/Modal.tsx
··· 17 17 import * as ChangeHandleModal from './ChangeHandle' 18 18 import * as WaitlistModal from './Waitlist' 19 19 import * as InviteCodesModal from './InviteCodes' 20 + import * as AddAppPassword from './AddAppPasswords' 20 21 import * as ContentFilteringSettingsModal from './ContentFilteringSettings' 21 22 22 23 const DEFAULT_SNAPPOINTS = ['90%'] ··· 81 82 } else if (activeModal?.name === 'invite-codes') { 82 83 snapPoints = InviteCodesModal.snapPoints 83 84 element = <InviteCodesModal.Component /> 85 + } else if (activeModal?.name === 'add-app-password') { 86 + snapPoints = AddAppPassword.snapPoints 87 + element = <AddAppPassword.Component /> 84 88 } else if (activeModal?.name === 'content-filtering-settings') { 85 89 snapPoints = ContentFilteringSettingsModal.snapPoints 86 90 element = <ContentFilteringSettingsModal.Component />
+24 -1
src/view/com/util/ViewHeader.tsx
··· 3 3 import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native' 4 4 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 5 5 import {useNavigation} from '@react-navigation/native' 6 + import {CenteredView} from './Views' 6 7 import {UserAvatar} from './UserAvatar' 7 8 import {Text} from './text/Text' 8 9 import {useStores} from 'state/index' ··· 18 19 title, 19 20 canGoBack, 20 21 hideOnScroll, 22 + showOnDesktop, 21 23 }: { 22 24 title: string 23 25 canGoBack?: boolean 24 26 hideOnScroll?: boolean 27 + showOnDesktop?: boolean 25 28 }) { 26 29 const pal = usePalette('default') 27 30 const store = useStores() ··· 42 45 }, [track, store]) 43 46 44 47 if (isDesktopWeb) { 45 - return <></> 48 + if (showOnDesktop) { 49 + return <DesktopWebHeader title={title} /> 50 + } 51 + return null 46 52 } else { 47 53 if (typeof canGoBack === 'undefined') { 48 54 canGoBack = navigation.canGoBack() ··· 76 82 } 77 83 }) 78 84 85 + function DesktopWebHeader({title}: {title: string}) { 86 + const pal = usePalette('default') 87 + return ( 88 + <CenteredView style={[styles.header, styles.desktopHeader, pal.border]}> 89 + <View style={styles.titleContainer} pointerEvents="none"> 90 + <Text type="title-lg" style={[pal.text, styles.title]}> 91 + {title} 92 + </Text> 93 + </View> 94 + </CenteredView> 95 + ) 96 + } 97 + 79 98 const Container = observer( 80 99 ({ 81 100 children, ··· 132 151 position: 'absolute', 133 152 top: 0, 134 153 width: '100%', 154 + }, 155 + desktopHeader: { 156 + borderBottomWidth: 1, 157 + paddingVertical: 12, 135 158 }, 136 159 137 160 titleContainer: {
+275
src/view/screens/AppPasswords.tsx
··· 1 + import React from 'react' 2 + import {Alert, StyleSheet, TouchableOpacity, View} from 'react-native' 3 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 + import {ScrollView} from 'react-native-gesture-handler' 5 + import {Text} from '../com/util/text/Text' 6 + import {Button} from '../com/util/forms/Button' 7 + import * as Toast from '../com/util/Toast' 8 + import {useStores} from 'state/index' 9 + import {usePalette} from 'lib/hooks/usePalette' 10 + import {isDesktopWeb} from 'platform/detection' 11 + import {withAuthRequired} from 'view/com/auth/withAuthRequired' 12 + import {observer} from 'mobx-react-lite' 13 + import {NativeStackScreenProps} from '@react-navigation/native-stack' 14 + import {CommonNavigatorParams} from 'lib/routes/types' 15 + import {useAnalytics} from 'lib/analytics' 16 + import {useFocusEffect} from '@react-navigation/native' 17 + import {ViewHeader} from '../com/util/ViewHeader' 18 + import {CenteredView} from 'view/com/util/Views' 19 + 20 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'> 21 + export const AppPasswords = withAuthRequired( 22 + observer(({}: Props) => { 23 + const pal = usePalette('default') 24 + const store = useStores() 25 + const {screen} = useAnalytics() 26 + 27 + useFocusEffect( 28 + React.useCallback(() => { 29 + screen('Settings') 30 + store.shell.setMinimalShellMode(false) 31 + }, [screen, store]), 32 + ) 33 + 34 + const onAdd = React.useCallback(async () => { 35 + store.shell.openModal({name: 'add-app-password'}) 36 + }, [store]) 37 + 38 + // no app passwords (empty) state 39 + if (store.me.appPasswords.length === 0) { 40 + return ( 41 + <CenteredView 42 + style={[ 43 + styles.container, 44 + isDesktopWeb && styles.containerDesktop, 45 + pal.view, 46 + pal.border, 47 + ]} 48 + testID="appPasswordsScreen"> 49 + <AppPasswordsHeader /> 50 + <View style={[styles.empty, pal.viewLight]}> 51 + <Text type="lg" style={[pal.text, styles.emptyText]}> 52 + You have not created any app passwords yet. You can create one by 53 + pressing the button below. 54 + </Text> 55 + </View> 56 + {!isDesktopWeb && <View style={styles.flex1} />} 57 + <View 58 + style={[ 59 + styles.btnContainer, 60 + isDesktopWeb && styles.btnContainerDesktop, 61 + ]}> 62 + <Button 63 + testID="appPasswordBtn" 64 + type="primary" 65 + label="Add App Password" 66 + style={styles.btn} 67 + labelStyle={styles.btnLabel} 68 + onPress={onAdd} 69 + /> 70 + </View> 71 + </CenteredView> 72 + ) 73 + } 74 + 75 + // has app passwords 76 + return ( 77 + <CenteredView 78 + style={[ 79 + styles.container, 80 + isDesktopWeb && styles.containerDesktop, 81 + pal.view, 82 + pal.border, 83 + ]} 84 + testID="appPasswordsScreen"> 85 + <AppPasswordsHeader /> 86 + <ScrollView 87 + style={[ 88 + styles.scrollContainer, 89 + pal.border, 90 + !isDesktopWeb && styles.flex1, 91 + ]}> 92 + {store.me.appPasswords.map((password, i) => ( 93 + <AppPassword 94 + key={password.name} 95 + testID={`appPassword-${i}`} 96 + name={password.name} 97 + createdAt={password.createdAt} 98 + /> 99 + ))} 100 + {isDesktopWeb && ( 101 + <View style={[styles.btnContainer, styles.btnContainerDesktop]}> 102 + <Button 103 + testID="appPasswordBtn" 104 + type="primary" 105 + label="Add App Password" 106 + style={styles.btn} 107 + labelStyle={styles.btnLabel} 108 + onPress={onAdd} 109 + /> 110 + </View> 111 + )} 112 + </ScrollView> 113 + {!isDesktopWeb && ( 114 + <View style={styles.btnContainer}> 115 + <Button 116 + testID="appPasswordBtn" 117 + type="primary" 118 + label="Add App Password" 119 + style={styles.btn} 120 + labelStyle={styles.btnLabel} 121 + onPress={onAdd} 122 + /> 123 + </View> 124 + )} 125 + </CenteredView> 126 + ) 127 + }), 128 + ) 129 + 130 + function AppPasswordsHeader() { 131 + const pal = usePalette('default') 132 + return ( 133 + <> 134 + <ViewHeader title="App Passwords" showOnDesktop /> 135 + <Text 136 + type="sm" 137 + style={[ 138 + styles.description, 139 + pal.text, 140 + isDesktopWeb && styles.descriptionDesktop, 141 + ]}> 142 + These passwords can be used to log onto Bluesky in other apps without 143 + giving them full access to your account or your password. 144 + </Text> 145 + </> 146 + ) 147 + } 148 + 149 + function AppPassword({ 150 + testID, 151 + name, 152 + createdAt, 153 + }: { 154 + testID: string 155 + name: string 156 + createdAt: string 157 + }) { 158 + const pal = usePalette('default') 159 + const store = useStores() 160 + 161 + const onDelete = React.useCallback(async () => { 162 + Alert.alert( 163 + 'Delete App Password', 164 + `Are you sure you want to delete the app password "${name}"?`, 165 + [ 166 + { 167 + text: 'Cancel', 168 + style: 'cancel', 169 + }, 170 + { 171 + text: 'Delete', 172 + style: 'destructive', 173 + onPress: async () => { 174 + await store.me.deleteAppPassword(name) 175 + Toast.show('App password deleted') 176 + }, 177 + }, 178 + ], 179 + ) 180 + }, [store, name]) 181 + 182 + return ( 183 + <TouchableOpacity 184 + testID={testID} 185 + style={[styles.item, pal.border]} 186 + onPress={onDelete}> 187 + <Text type="md-bold" style={pal.text}> 188 + {name} 189 + </Text> 190 + <View style={styles.flex1} /> 191 + <Text type="md" style={[pal.text, styles.pr10]}> 192 + {new Date(createdAt).toDateString()} 193 + </Text> 194 + <FontAwesomeIcon icon={['far', 'trash-can']} style={styles.trashIcon} /> 195 + </TouchableOpacity> 196 + ) 197 + } 198 + 199 + const styles = StyleSheet.create({ 200 + container: { 201 + flex: 1, 202 + paddingBottom: isDesktopWeb ? 0 : 100, 203 + }, 204 + containerDesktop: { 205 + borderLeftWidth: 1, 206 + borderRightWidth: 1, 207 + }, 208 + title: { 209 + textAlign: 'center', 210 + marginTop: 12, 211 + marginBottom: 12, 212 + }, 213 + description: { 214 + textAlign: 'center', 215 + paddingHorizontal: 20, 216 + marginBottom: 14, 217 + }, 218 + descriptionDesktop: { 219 + marginTop: 14, 220 + }, 221 + 222 + scrollContainer: { 223 + borderTopWidth: 1, 224 + marginTop: 4, 225 + marginBottom: 16, 226 + }, 227 + 228 + flex1: { 229 + flex: 1, 230 + }, 231 + empty: { 232 + paddingHorizontal: 20, 233 + paddingVertical: 20, 234 + borderRadius: 16, 235 + marginHorizontal: 24, 236 + marginTop: 10, 237 + }, 238 + emptyText: { 239 + textAlign: 'center', 240 + }, 241 + 242 + item: { 243 + flexDirection: 'row', 244 + alignItems: 'center', 245 + borderBottomWidth: 1, 246 + paddingHorizontal: 20, 247 + paddingVertical: 14, 248 + }, 249 + pr10: { 250 + marginRight: 10, 251 + }, 252 + 253 + btnContainer: { 254 + flexDirection: 'row', 255 + justifyContent: 'center', 256 + }, 257 + btnContainerDesktop: { 258 + marginTop: 14, 259 + }, 260 + btn: { 261 + flexDirection: 'row', 262 + alignItems: 'center', 263 + justifyContent: 'center', 264 + borderRadius: 32, 265 + paddingHorizontal: 60, 266 + paddingVertical: 14, 267 + }, 268 + btnLabel: { 269 + fontSize: 18, 270 + }, 271 + 272 + trashIcon: { 273 + color: 'red', 274 + }, 275 + })
+1 -1
src/view/screens/PostLikedBy.tsx
··· 22 22 23 23 return ( 24 24 <View> 25 - <ViewHeader title="Liked by" /> 25 + <ViewHeader title="Liked by" showOnDesktop /> 26 26 <PostLikedByComponent uri={uri} /> 27 27 </View> 28 28 )
+1 -1
src/view/screens/PostRepostedBy.tsx
··· 22 22 23 23 return ( 24 24 <View> 25 - <ViewHeader title="Reposted by" /> 25 + <ViewHeader title="Reposted by" showOnDesktop /> 26 26 <PostRepostedByComponent uri={uri} /> 27 27 </View> 28 28 )
+1 -1
src/view/screens/ProfileFollowers.tsx
··· 20 20 21 21 return ( 22 22 <View> 23 - <ViewHeader title="Followers" /> 23 + <ViewHeader title="Followers" showOnDesktop /> 24 24 <ProfileFollowersComponent name={name} /> 25 25 </View> 26 26 )
+1 -1
src/view/screens/ProfileFollows.tsx
··· 20 20 21 21 return ( 22 22 <View> 23 - <ViewHeader title="Following" /> 23 + <ViewHeader title="Following" showOnDesktop /> 24 24 <ProfileFollowsComponent name={name} /> 25 25 </View> 26 26 )
+15 -1
src/view/screens/Settings.tsx
··· 140 140 141 141 return ( 142 142 <View style={[s.hContentRegion]} testID="settingsScreen"> 143 - <ViewHeader title="Settings" /> 143 + <ViewHeader title="Settings" showOnDesktop /> 144 144 <ScrollView style={s.hContentRegion} scrollIndicatorInsets={{right: 1}}> 145 145 <View style={styles.spacer20} /> 146 146 <View style={[s.flexRow, styles.heading]}> ··· 267 267 Content moderation 268 268 </Text> 269 269 </TouchableOpacity> 270 + <Link 271 + testID="appPasswordBtn" 272 + style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} 273 + href="/settings/app-passwords"> 274 + <View style={[styles.iconContainer, pal.btn]}> 275 + <FontAwesomeIcon 276 + icon="lock" 277 + style={pal.text as FontAwesomeIconStyle} 278 + /> 279 + </View> 280 + <Text type="lg" style={pal.text}> 281 + App Passwords 282 + </Text> 283 + </Link> 270 284 <TouchableOpacity 271 285 testID="changeHandleBtn" 272 286 style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}