Merge main into the Web PR (#230)

* Update to RN 71.1.0 (#100)

* Update to RN 71

* Adds missing lint plugin

* Add missing native changes

* Bump @atproto/api@0.0.7 (#112)

* Image not loading on swipe (#114)

* Adds prefetching to images

* Adds image prefetch

* bugfix for images not showing on swipe

* Fixes prefetch bug

* Update src/view/com/util/PostEmbeds.tsx

---------

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

* Fixes to session management (#117)

* Update session-management to solve incorrectly dropped sessions

* Reset the nav on account switch

* Reset the feed on me.load()

* Update tests to reflect new account-switching behavior

* Increase max image resolutions and sizes (#118)

* Slightly increase the hitslop for post controls

* Fix character counter color in dark mode

* Update login to use new session.create api, which enables email login (close #93) (#119)

* Replaces the alert with dropdown for profile image and banner (#123)

* replaces the alert with dropdown for profile image and banner

* lint

* Fix to ordering of images in the embed grid (#121)

* Add explicit link-embed controls to the composer (#120)

* Add explicit link-embed controls

* Update the target rez/size of link embed thumbs

* Remove the alert before publishing without a link card

* [Draft] Fixes image failing on reupload issue (#128)

* Fixes image failing on reupload issue

* Use tmp folder instead of documents

* lint

* Image performance improvements (#126)

* Switch out most images for FastImage

* Add image loading placeholders

* Fix tests

* Collection of fixes to list rendering (#127)

* Fix bug that caused endless spinners in profile feeds

* Bundle fetches of suggested actors into one update

* Fixes to suggested follow rendering

* Fix missing replacement of flex:1 to height:100

* Fixes to navigation swipes (#129)

* Nav swipe: increase the distance traveled in response to gesture movement.

This causes swipes to feel faster and more responsive.

* Fix: fully clamp the swipe against the edge

* Improve the performance of swipes by skipping the interaction manager

* Adds dark mode to the edit screen (#130)

* Adds dark mode to edit screen

* lint

* lint

* lint

* Reduce render cost of post controls and improve perceived responsiveness (#132)

* Move post control animations into conditional render and increase perceived responsiveness

* Remove log

* Adds dark mode to the dropdown (#131)

* Adds dark mode to the bottom sheet

* Make background button lighter (like before)

* lint

* Fix bug in lightbox rendering (#133)

* Fix layout in onboarding to not overflow the footer

* Configure feed FlatList (removeClippedSubviews=true) to improve scroll performance (#136)

* Disable like/repost animations to see if theyre causing #135 (#137)

* Composer: mention tagging now works in middle of text (close #105) (#139)

* Implement account deletion (#141)

* Fix photo & camera permission management (#140)

* Check photo & camera perms and alert the user if not available (close #64)

- Adds perms checks with a prompt to update settings if needed
- Moves initial access of photos in the composer so that the initial prompt
  occurs at an intuitive time.

* Add react-native-permissions test mock

* Fix issue causing multiple access requests

* Use longer var names

* Update podfile.lock

* Lint fix

* Move photo perm request in composer to the gallery btn instead of when the carousel is opened

* Adds more tracking all around the app (#142)

* Adds more tracking all around the app

* more events

* lint

* using better analytics naming

* missed file

* more fixes

* Calculate image aspect ratio on load (#146)

* Calculate image aspect ratio on load

* Move aspect ratio bounds to constants

* Adds detox testing and instructions (#147)

* Adds detox testing and instructions

* lint

* lint

* Error cleanup (close #79) (#148)

* Avoid surfacing errors to the user when it's not critical

* Remove now-unused GetAssertionsView

* Apply cleanError() consistently

* Give a better error message for Upstream Failures (http status 502)

* Hide errors in notifications because they're not useful

* More e2e tests (create account) (#150)

* Adds respots under the 'post' tab under profile (#158)

* Adds dark mode to delete account screen (#159)

* 87 dark mode edit profile (#162)

* Adds dark mode to delete account screen

* Adds one more missed darkmode

* more fixes

* Remove fallback gradient on external links without thumbs (#164)

* Remove fallback gradient on external links without thumbs

* Remove fallback gradient on external links without thumbs in the composer preview

* Fix refresh behavior around a series of models (repost, graph, vote) (#163)

* Fix refresh behavior around a series of models (repost, graph, vote)

* Fix cursor behavior in reposted-by view

* Fixes issue where retrying on image upload fails (#166)

* Fixes issue where retrying on image upload fails

* Lint, longer test time

* Longer waitfor time in tests

* even longer timeout

* longer timeout

* missed file

* Update src/view/com/composer/ComposePost.tsx

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

* Update src/view/com/composer/ComposePost.tsx

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

---------

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

* 154 cached image profile (#167)

* Fixes issue where retrying on image upload fails

* Lint, longer test time

* Longer waitfor time in tests

* even longer timeout

* longer timeout

* missed file

* Fixes image cache error on second try for profile screen

* lint

* lint

* lint

* Refactor session management to use a new "Agent" API (#165)

* Add the atp-agent implementation (temporarily in this repo)

* Rewrite all session & API management to use the new atp-agent

* Update tests for the atp-agent refactor

* Refactor management of session-related state. Includes:
- More careful management of when state is cleared or fetched
- Debug logging to help trace future issues
- Clearer APIs overall

* Bubble session-expiration events to the user and display a toast to explain

* Switch to the new @atproto/api@0.1.0

* Minor aesthetic cleanup in SessionModel

* Wire up ReportAccount and ReportPost (#168)

* Fixes embeds for youtube channels (#169)

* Bump app ios version to 1.1 (needed after app store submission)

* Fix potential issues with promise guards when an error occurs (#170)

* Refactor models to use bundleAsync and lock regions (#171)

* Fix to an edge case with feed re-ordering for threads (#172)

* 151 fix youtube channel embed (#173)

* Fixes embeds for youtube channels

* Tests for youtube extract meta

* lint

* Add 'doesnt use non-exempt encryption' to ios config

* Rework the search UI and add  (#174)

* Add search tab and move icon to footer

* Remove subtitles from view header

* Remove unused code

* Clean up UI of search screen

* Search: give better user feedback to UI state and add a cancel button

* Add WhoToFollow section to search

* Add a temporary SuggestedPosts solution using the patented 'bsky team algo'

* Trigger reload of suggested content in search on open

* Wait five min between reloading discovery content

* Reduce weight of solid search icon in footer

* Fix lint

* Fix tests

* 151 feat youtube embed iframe (#176)

* youtube embed iframe temp commit

* Fixes styling and code cleanup

* lint

* Now clicking between the pause and settings button doesn't trigger the parent

* use modest branding (less yt logos)

* Stop playing the video once there's a navigation event

* Make sure the iframe is unmounted on any navigation event

* fixes tests

* lint

* Add scroll-to-top for all screens (#177)

* Adds hardcoded suggested list (#178)

* Adds hardcoded suggested list

* Update suggested-actors-view to support page sizes smaller than the hardcoded list

---------

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

* more robust centering of the play button (#181)

Co-authored-by: Aryan Goharzad <arrygoo@gmail.com>

* Bundle of UI modifications (#175)

* Adjust visual balance of SuggestedPosts and WhoToFollow

* Fix bug in the discovery load trigger

* Adjust search header aesthetic and have it scroll away

* More visual balance tweaks on the search page

* Even more visual balance tweaks on the search page

* Hide the footer on scroll in search

* Ditch the composer prompt buttons in the home feed

* Center the view header title

* Hide header on scroll on the home feed

* Fix e2e tests

* Fix home feed positioning (closes #189) (#195)

* Fix home feed positioning for floating header

* Fix positioning of errors in home feed

* Fix lint

* Don't show new-content notification for reposts (close #179) (#197)

* Show the splash screen during session resumption (close #186) (#199)

* Fix to suggested follows: chunk the hardcoded fetches to 25 at a time (close #196) (#198)

* UI updates to the floating action button (#201)

* Update FAB to use a plus icon and not drop shadow

* Update FAB positioning to be more consistent in different shell modes

* Animate the FAB's repositioning

* Remove the 'loading' placeholder from images as it degraded feed perf (#202)

* Remove the 'loading' placeholder from images as it degraded feed perf

* Remove references

* Fix RN bug that causes home feed not to load more; also fix home feed load view. (#208)

RN has a bug where rendering a flatlist with an empty array appears to break its
virtual list windowing behaviors. See https://stackoverflow.com/a/67873596

* Only give the loading spinner on the home feed during PTR (#207)

(cherry picked from commit b7a5da12fdfacef74873b5cf6d75f20d259bde0e)

* Implement our own lifecycle tracking to ensure it never fires while the app is backgrounded (close #193) (#211)

* Push notification fixes (#210)

* Fix to when screen analytics events are firing

* Fix: dont trigger update state when backgrounded

* Small fix to notifee API usage

* Fix: properly load notification info for push card

* Add feedback link to main menu (close #191) (#212)

* Add "follows you" information and sync follow state between views (#215)

* Bump @atproto/api@0.1.2 and update API usage

* Add 'follows you' pill to profile header (close #110)

* Add 'follows you' to followers and follows (close #103)

* Update reposted-by and liked-by views to use the same components as followers and following

* Create a local follows cache MyFollowsModel to keep views in sync (close #205)

* Add incremental hydration to the MyFollows model

* Fix tests

* Update deps

* Fix lint

* Fix to paginated fetches

* Fix reference

* Fix potential state-desync issue

* Fixes to notifications (#216)

* Improve push-notification for follows

* Refresh notifications on screen open (close #214)

* Avoid showing loader more than needed in post threads

* Refactor notification polling to handle view-state more effectively

* Delete a bunch of tests taht werent adding value

* Remove the accounts integration test; we'll use the e2e test instead

* Load latest in notifications when the screen is open rather than full refresh

* Randomize hard-coded suggested follows (#226)

* Ensure follows are loaded before filtering hardcoded suggestions

* Randomize hard-coded suggested profiles (close #219)

* Sanitizes posts on publish and render (#217)

* Sanatizes posts on publish and render

* lint

* lint and added sanitize to thread view as well

* adjusts indices based on replaced text

* Woops, fixes a bug

* bugfix + cleanup

* comment

* lint

* move sanitize text to later in the flow

* undo changes to compose post

* Add RichText library building upon the sanitizePost library method

* Add lodash.clonedeep dep

* Switch to RichText processing on record load & render

* Fix lint

---------

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

* A group of notifications fixes (#227)

* Fix: don't group together notifications that can't visually be grouped (close #221)

* Mark all notifications read on PTR

* Small optimization: useCallback and useMemo in posts feed

* Add loading spinner to footer of notifications (close #222)

* Fix to scrolling to posts within a thread (#228)

* Fix: render the entire thread at start so that scrollToIndex works always (close #270)

* Visual fixes to thread 'load more'

* A few small perf improvements to thread rendering

* Fix lint

* 1.2

* Remove unused logger lib

* Remove state-mock

* Type fixes

* Reorganize the folder structure for lib and switch to typescript path aliases

* Move build-flags into lib

* Move to the state path alias

* Add view path alias

* Fix lint

* iOS build fixes

* Wrap analytics in native/web splitter and re-enable in all view code

* Add web version of react-native-webview

* Add web split for version number

* Fix BlurView import for web

* Add web split for fastimage

* Create web split for permissions lib

* Fix for web high priority images

---------

Co-authored-by: Aryan Goharzad <arrygoo@gmail.com>
This commit is contained in:
Paul Frazee 2023-02-22 14:23:57 -06:00 committed by GitHub
parent 7916b26aad
commit f28334739b
242 changed files with 8400 additions and 7454 deletions

View file

@ -5,9 +5,9 @@ import {
StyleSheet,
useWindowDimensions,
} from 'react-native'
import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {usePalette} from 'lib/hooks/usePalette'
import {Text} from '../util/text/Text'
import {usePalette} from '../../lib/hooks/usePalette'
interface AutocompleteItem {
handle: string

View file

@ -3,10 +3,12 @@ import {observer} from 'mobx-react-lite'
import {
ActivityIndicator,
KeyboardAvoidingView,
NativeSyntheticEvent,
Platform,
SafeAreaView,
ScrollView,
StyleSheet,
TextInputSelectionChangeEventData,
TouchableOpacity,
TouchableWithoutFeedback,
View,
@ -16,8 +18,9 @@ import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
// import {useAnalytics} from '@segment/analytics-react-native' TODO
import {UserAutocompleteViewModel} from '../../../state/models/user-autocomplete-view'
import {useAnalytics} from 'lib/analytics'
import _isEqual from 'lodash.isequal'
import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view'
import {Autocomplete} from './Autocomplete'
import {ExternalEmbed} from './ExternalEmbed'
import {Text} from '../util/text/Text'
@ -26,24 +29,27 @@ import {TextInput, TextInputRef} from './text-input/TextInput'
import {CharProgress} from './char-progress/CharProgress'
import {TextLink} from '../util/Link'
import {UserAvatar} from '../util/UserAvatar'
import {useStores} from '../../../state'
import * as apilib from '../../../state/lib/api'
import {ComposerOpts} from '../../../state/models/shell-ui'
import {s, colors, gradients} from '../../lib/styles'
import {
detectLinkables,
extractEntities,
cleanError,
} from '../../../lib/strings'
import {getLinkMeta} from '../../../lib/link-meta'
import {downloadAndResize} from '../../../lib/images'
import {useStores} from 'state/index'
import * as apilib from 'lib/api/index'
import {ComposerOpts} from 'state/models/shell-ui'
import {s, colors, gradients} from 'lib/styles'
import {cleanError} from 'lib/strings/errors'
import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection'
import {getLinkMeta} from 'lib/link-meta/link-meta'
import {downloadAndResize} from 'lib/images'
import {PhotoCarouselPicker, cropPhoto} from './photos/PhotoCarouselPicker'
import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
import {SelectedPhoto} from './SelectedPhoto'
import {usePalette} from '../../lib/hooks/usePalette'
import {usePalette} from 'lib/hooks/usePalette'
const MAX_TEXT_LENGTH = 256
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
interface Selection {
start: number
end: number
}
export const ComposePost = observer(function ComposePost({
replyTo,
imagesOpen,
@ -55,10 +61,11 @@ export const ComposePost = observer(function ComposePost({
onPost?: ComposerOpts['onPost']
onClose: () => void
}) {
// const {track} = useAnalytics() TODO
const {track, screen} = useAnalytics()
const pal = usePalette('default')
const store = useStores()
const textInput = useRef<TextInputRef>(null)
const textInputSelection = useRef<Selection>({start: 0, end: 0})
const [isProcessing, setIsProcessing] = useState(false)
const [processingState, setProcessingState] = useState('')
const [error, setError] = useState('')
@ -66,7 +73,9 @@ export const ComposePost = observer(function ComposePost({
const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>(
undefined,
)
const [attemptedExtLinks, setAttemptedExtLinks] = useState<string[]>([])
const [suggestedExtLinks, setSuggestedExtLinks] = useState<Set<string>>(
new Set(),
)
const [isSelectingPhotos, setIsSelectingPhotos] = useState(
imagesOpen || false,
)
@ -117,10 +126,10 @@ export const ComposePost = observer(function ComposePost({
if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) {
downloadAndResize({
uri: extLink.meta.image,
width: 250,
height: 250,
width: 2000,
height: 2000,
mode: 'contain',
maxSize: 100000,
maxSize: 1000000,
timeout: 15e3,
})
.catch(() => undefined)
@ -166,6 +175,7 @@ export const ComposePost = observer(function ComposePost({
textInput.current?.focus()
}
const onPressSelectPhotos = () => {
track('ComposePost:SelectPhotos')
if (isSelectingPhotos) {
setIsSelectingPhotos(false)
} else if (selectedPhotos.length < 4) {
@ -173,35 +183,31 @@ export const ComposePost = observer(function ComposePost({
}
}
const onSelectPhotos = (photos: string[]) => {
track('ComposePost:SelectPhotos:Done')
setSelectedPhotos(photos)
if (photos.length >= 4) {
setIsSelectingPhotos(false)
}
}
const onPressAddLinkCard = (uri: string) => {
setExtLink({uri, isLoading: true})
}
const onChangeText = (newText: string) => {
setText(newText)
const prefix = extractTextAutocompletePrefix(newText)
if (typeof prefix === 'string') {
const prefix = getMentionAt(newText, textInputSelection.current?.start || 0)
if (prefix) {
autocompleteView.setActive(true)
autocompleteView.setPrefix(prefix)
autocompleteView.setPrefix(prefix.value)
} else {
autocompleteView.setActive(false)
}
if (!extLink && /\s$/.test(newText)) {
const ents = extractEntities(newText)
const entLink = ents
?.filter(
ent => ent.type === 'link' && !attemptedExtLinks.includes(ent.value),
)
.pop() // use last
if (entLink) {
setExtLink({
uri: entLink.value,
isLoading: true,
})
setAttemptedExtLinks([...attemptedExtLinks, entLink.value])
if (!extLink) {
const ents = extractEntities(newText)?.filter(ent => ent.type === 'link')
const set = new Set(ents ? ents.map(e => e.value) : [])
if (!_isEqual(set, suggestedExtLinks)) {
setSuggestedExtLinks(set)
}
}
}
@ -218,6 +224,16 @@ export const ComposePost = observer(function ComposePost({
onSelectPhotos([...selectedPhotos, finalImgPath])
}
}
const onSelectionChange = (
evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>,
) => {
// NOTE we track the input selection using a ref to avoid excessive renders -prf
textInputSelection.current = evt.nativeEvent.selection
}
const onSelectAutocompleteItem = (item: string) => {
setText(insertMentionAt(text, textInputSelection.current?.start || 0, item))
autocompleteView.setActive(false)
}
const onPressCancel = () => hackfixOnClose()
const onPressPublish = async () => {
if (isProcessing) {
@ -242,11 +258,15 @@ export const ComposePost = observer(function ComposePost({
autocompleteView.knownHandles,
setProcessingState,
)
// TODO
// track('Create Post', {
// imageCount: selectedPhotos.length,
// })
track('Create Post', {
imageCount: selectedPhotos.length,
})
} catch (e: any) {
setExtLink({
...extLink,
isLoading: true,
localThumb: undefined,
} as apilib.ExternalEmbedDraft)
setError(cleanError(e.message))
setIsProcessing(false)
return
@ -256,10 +276,6 @@ export const ComposePost = observer(function ComposePost({
hackfixOnClose()
Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
}
const onSelectAutocompleteItem = (item: string) => {
setText(replaceTextAutocompletePrefix(text, item))
autocompleteView.setActive(false)
}
const canPost = text.length <= MAX_TEXT_LENGTH
@ -386,6 +402,7 @@ export const ComposePost = observer(function ComposePost({
innerRef={textInput}
onChangeText={(str: string) => onChangeText(str)}
onPaste={onPaste}
onSelectionChange={onSelectionChange}
placeholder={selectTextInputPlaceholder}
style={[
pal.text,
@ -406,12 +423,27 @@ export const ComposePost = observer(function ComposePost({
/>
)}
</ScrollView>
{isSelectingPhotos && selectedPhotos.length < 4 && (
{isSelectingPhotos && selectedPhotos.length < 4 ? (
<PhotoCarouselPicker
selectedPhotos={selectedPhotos}
onSelectPhotos={onSelectPhotos}
/>
)}
) : !extLink &&
selectedPhotos.length === 0 &&
suggestedExtLinks.size > 0 ? (
<View style={s.mb5}>
{Array.from(suggestedExtLinks).map(url => (
<TouchableOpacity
key={`suggested-${url}`}
style={[pal.borderDark, styles.addExtLinkBtn]}
onPress={() => onPressAddLinkCard(url)}>
<Text>
Add link card: <Text style={pal.link}>{url}</Text>
</Text>
</TouchableOpacity>
))}
</View>
) : null}
<View style={[pal.border, styles.bottomBar]}>
<TouchableOpacity
testID="composerSelectPhotosButton"
@ -442,18 +474,6 @@ export const ComposePost = observer(function ComposePost({
)
})
const atPrefixRegex = /@([a-z0-9.]*)$/i
function extractTextAutocompletePrefix(text: string) {
const match = atPrefixRegex.exec(text)
if (match) {
return match[1]
}
return undefined
}
function replaceTextAutocompletePrefix(text: string, item: string) {
return text.replace(atPrefixRegex, `@${item} `)
}
const styles = StyleSheet.create({
outer: {
flexDirection: 'column',
@ -532,6 +552,13 @@ const styles = StyleSheet.create({
paddingLeft: 13,
paddingRight: 8,
},
addExtLinkBtn: {
borderWidth: 1,
borderRadius: 24,
paddingHorizontal: 16,
paddingVertical: 12,
marginBottom: 4,
},
bottomBar: {
flexDirection: 'row',
paddingVertical: 10,

View file

@ -7,12 +7,11 @@ import {
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {BlurView} from '../util/BlurView'
import LinearGradient from 'react-native-linear-gradient'
import {AutoSizedImage} from '../util/images/AutoSizedImage'
import {Text} from '../util/text/Text'
import {s, gradients} from '../../lib/styles'
import {usePalette} from '../../lib/hooks/usePalette'
import {ExternalEmbedDraft} from '../../../state/lib/api'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {ExternalEmbedDraft} from 'lib/api/index'
export const ExternalEmbed = ({
link,
@ -30,31 +29,12 @@ export const ExternalEmbed = ({
<View style={[styles.outer, pal.view, pal.border]}>
{link.isLoading ? (
<View
style={[
styles.image,
styles.imageFallback,
{backgroundColor: pal.colors.backgroundLight},
]}>
style={[styles.image, {backgroundColor: pal.colors.backgroundLight}]}>
<ActivityIndicator size="large" style={styles.spinner} />
</View>
) : link.localThumb ? (
<AutoSizedImage
uri={link.localThumb.path}
containerStyle={styles.image}
/>
) : (
<LinearGradient
colors={[gradients.blueDark.start, gradients.blueDark.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.image, styles.imageFallback]}
/>
)}
<TouchableWithoutFeedback onPress={onRemove}>
<BlurView style={styles.removeBtn} blurType="dark">
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
</BlurView>
</TouchableWithoutFeedback>
<AutoSizedImage uri={link.localThumb.path} style={styles.image} />
) : undefined}
<View style={styles.inner}>
{!!link.meta?.title && (
<Text type="sm-bold" numberOfLines={2} style={[pal.text]}>
@ -81,6 +61,11 @@ export const ExternalEmbed = ({
</Text>
)}
</View>
<TouchableWithoutFeedback onPress={onRemove}>
<BlurView style={styles.removeBtn} blurType="dark">
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
</BlurView>
</TouchableWithoutFeedback>
</View>
)
}
@ -98,10 +83,7 @@ const styles = StyleSheet.create({
borderTopLeftRadius: 6,
borderTopRightRadius: 6,
width: '100%',
height: 200,
},
imageFallback: {
height: 160,
maxHeight: 200,
},
removeBtn: {
position: 'absolute',

View file

@ -2,7 +2,7 @@ import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../util/text/Text'
import {usePalette} from '../../lib/hooks/usePalette'
import {usePalette} from 'lib/hooks/usePalette'
export function ComposePrompt({
text = "What's up?",

View file

@ -1,7 +1,8 @@
import React, {useCallback} from 'react'
import {Image, StyleSheet, TouchableOpacity, View} from 'react-native'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {colors} from '../../lib/styles'
import Image from 'view/com/util/images/Image'
import {colors} from 'lib/styles'
export const SelectedPhoto = ({
selectedPhotos,

View file

@ -5,7 +5,7 @@ import {Text} from '../../util/text/Text'
import ProgressCircle from 'react-native-progress/Circle'
// @ts-ignore no type definition -prf
import ProgressPie from 'react-native-progress/Pie'
import {s, colors} from '../../../lib/styles'
import {s, colors} from 'lib/styles'
const MAX_TEXT_LENGTH = 256
const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH

View file

@ -1,7 +1,7 @@
import React from 'react'
import {View} from 'react-native'
import {Text} from '../../util/text/Text'
import {s} from '../../../lib/styles'
import {s} from 'lib/styles'
const MAX_TEXT_LENGTH = 256
const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH

View file

@ -4,6 +4,7 @@ import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {useAnalytics} from 'lib/analytics'
import {
openPicker,
openCamera,
@ -12,18 +13,26 @@ import {
import {
UserLocalPhotosModel,
PhotoIdentifier,
} from '../../../../state/models/user-local-photos'
import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images'
import {usePalette} from '../../../lib/hooks/usePalette'
import {useStores, RootStoreModel} from '../../../../state'
} from 'state/models/user-local-photos'
import {
compressIfNeeded,
moveToPremanantPath,
scaleDownDimensions,
} from 'lib/images'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores, RootStoreModel} from 'state/index'
import {
requestPhotoAccessIfNeeded,
requestCameraAccessIfNeeded,
} from 'lib/permissions'
const MAX_WIDTH = 1000
const MAX_HEIGHT = 1000
const MAX_SIZE = 300000
const MAX_WIDTH = 2000
const MAX_HEIGHT = 2000
const MAX_SIZE = 1000000
const IMAGE_PARAMS = {
width: 1000,
height: 1000,
width: 2000,
height: 2000,
freeStyleCropEnabled: true,
}
@ -46,8 +55,10 @@ export async function cropPhoto(
width,
height,
})
const img = await compressIfNeeded(cropperRes, MAX_SIZE)
return img.path
const permanentPath = await moveToPremanantPath(img.path)
return permanentPath
}
export const PhotoCarouselPicker = ({
@ -57,24 +68,28 @@ export const PhotoCarouselPicker = ({
selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void
}) => {
const {track} = useAnalytics()
const pal = usePalette('default')
const store = useStores()
const [localPhotos, setLocalPhotos] = React.useState<
UserLocalPhotosModel | undefined
>(undefined)
const [isSetup, setIsSetup] = React.useState<boolean>(false)
const localPhotos = React.useMemo<UserLocalPhotosModel>(
() => new UserLocalPhotosModel(store),
[store],
)
// initial setup
React.useEffect(() => {
const photos = new UserLocalPhotosModel(store)
photos.setup().then(() => {
if (photos.photos) {
setLocalPhotos(photos)
}
// initial setup
localPhotos.setup().then(() => {
setIsSetup(true)
})
}, [store])
}, [localPhotos])
const handleOpenCamera = useCallback(async () => {
try {
if (!(await requestCameraAccessIfNeeded())) {
return
}
const cameraRes = await openCamera(store, {
mediaType: 'photo',
...IMAGE_PARAMS,
@ -89,6 +104,7 @@ export const PhotoCarouselPicker = ({
const handleSelectPhoto = useCallback(
async (item: PhotoIdentifier) => {
track('PhotoCarouselPicker:PhotoSelected')
try {
const imgPath = await cropPhoto(
store,
@ -102,37 +118,41 @@ export const PhotoCarouselPicker = ({
store.log.warn('Error selecting photo', err)
}
},
[store, selectedPhotos, onSelectPhotos],
[track, store, onSelectPhotos, selectedPhotos],
)
const handleOpenGallery = useCallback(() => {
openPicker(store, {
const handleOpenGallery = useCallback(async () => {
track('PhotoCarouselPicker:GalleryOpened')
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker(store, {
multiple: true,
maxFiles: 4 - selectedPhotos.length,
mediaType: 'photo',
}).then(async items => {
const result = []
for (const image of items) {
// choose target dimensions based on the original
// this causes the photo cropper to start with the full image "selected"
const {width, height} = scaleDownDimensions(
{width: image.width, height: image.height},
{width: MAX_WIDTH, height: MAX_HEIGHT},
)
const cropperRes = await openCropper(store, {
mediaType: 'photo',
path: image.path,
freeStyleCropEnabled: true,
width,
height,
})
const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE)
result.push(finalImg.path)
}
onSelectPhotos([...selectedPhotos, ...result])
})
}, [store, selectedPhotos, onSelectPhotos])
const result = []
for (const image of items) {
// choose target dimensions based on the original
// this causes the photo cropper to start with the full image "selected"
const {width, height} = scaleDownDimensions(
{width: image.width, height: image.height},
{width: MAX_WIDTH, height: MAX_HEIGHT},
)
const cropperRes = await openCropper(store, {
mediaType: 'photo',
path: image.path,
...IMAGE_PARAMS,
width,
height,
})
const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE)
const permanentPath = await moveToPremanantPath(finalImg.path)
result.push(permanentPath)
}
onSelectPhotos([...selectedPhotos, ...result])
}, [track, store, selectedPhotos, onSelectPhotos])
return (
<ScrollView
@ -161,7 +181,7 @@ export const PhotoCarouselPicker = ({
size={24}
/>
</TouchableOpacity>
{localPhotos != null &&
{isSetup &&
localPhotos.photos.map((item: PhotoIdentifier, index: number) => (
<TouchableOpacity
testID="openSelectPhotoButton"

View file

@ -9,9 +9,9 @@ import {
openCamera,
openCropper,
} from '../../util/images/image-crop-picker/ImageCropPicker'
import {compressIfNeeded, scaleDownDimensions} from '../../../../lib/images'
import {usePalette} from '../../../lib/hooks/usePalette'
import {useStores, RootStoreModel} from '../../../../state'
import {compressIfNeeded, scaleDownDimensions} from 'lib/images'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores, RootStoreModel} from 'state/index'
const MAX_WIDTH = 1000
const MAX_HEIGHT = 1000

View file

@ -1,10 +1,15 @@
import React from 'react'
import {StyleProp, TextStyle} from 'react-native'
import {
NativeSyntheticEvent,
StyleProp,
TextInputSelectionChangeEventData,
TextStyle,
} from 'react-native'
import PasteInput, {
PastedFile,
PasteInputRef,
} from '@mattermost/react-native-paste-input'
import {usePalette} from '../../../lib/hooks/usePalette'
import {usePalette} from 'lib/hooks/usePalette'
export type TextInputRef = PasteInputRef
@ -14,6 +19,9 @@ interface TextInputProps {
placeholder: string
style: StyleProp<TextStyle>
onChangeText: (str: string) => void
onSelectionChange?:
| ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void)
| undefined
onPaste: (err: string | undefined, uris: string[]) => void
}
@ -23,6 +31,7 @@ export function TextInput({
placeholder,
style,
onChangeText,
onSelectionChange,
onPaste,
children,
}: React.PropsWithChildren<TextInputProps>) {
@ -44,6 +53,7 @@ export function TextInput({
multiline
scrollEnabled
onChangeText={(str: string) => onChangeText(str)}
onSelectionChange={onSelectionChange}
onPaste={onPasteInner}
placeholder={placeholder}
placeholderTextColor={pal.colors.textLight}

View file

@ -1,12 +1,14 @@
import React from 'react'
import {
NativeSyntheticEvent,
StyleProp,
StyleSheet,
TextInput as RNTextInput,
TextInputSelectionChangeEventData,
TextStyle,
} from 'react-native'
import {usePalette} from '../../../lib/hooks/usePalette'
import {addStyle} from '../../../lib/addStyle'
import {usePalette} from 'lib/hooks/usePalette'
import {addStyle} from 'lib/styles'
export type TextInputRef = RNTextInput
@ -16,6 +18,9 @@ interface TextInputProps {
placeholder: string
style: StyleProp<TextStyle>
onChangeText: (str: string) => void
onSelectionChange?:
| ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void)
| undefined
onPaste: (err: string | undefined, uris: string[]) => void
}
@ -25,6 +30,7 @@ export function TextInput({
placeholder,
style,
onChangeText,
onSelectionChange,
children,
}: React.PropsWithChildren<TextInputProps>) {
const pal = usePalette('default')
@ -36,6 +42,7 @@ export function TextInput({
multiline
scrollEnabled
onChangeText={(str: string) => onChangeText(str)}
onSelectionChange={onSelectionChange}
placeholder={placeholder}
placeholderTextColor={pal.colors.textLight}
style={style}>

View file

@ -8,19 +8,18 @@ import {
import LinearGradient from 'react-native-linear-gradient'
import {observer} from 'mobx-react-lite'
import _omit from 'lodash.omit'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
import * as Toast from '../util/Toast'
import {useStores} from '../../../state'
import * as apilib from '../../../state/lib/api'
import {useStores} from 'state/index'
import * as apilib from 'lib/api/index'
import {
SuggestedActorsViewModel,
SuggestedActor,
} from '../../../state/models/suggested-actors-view'
import {s, gradients} from '../../lib/styles'
import {usePalette} from '../../lib/hooks/usePalette'
} from 'state/models/suggested-actors-view'
import {s, gradients} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
export const LiteSuggestedFollows = observer(() => {
const store = useStores()

View file

@ -1,51 +1,28 @@
import React, {useEffect, useState} from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {observer} from 'mobx-react-lite'
import _omit from 'lodash.omit'
import React from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {CenteredView, FlatList} from '../util/Views'
import {observer} from 'mobx-react-lite'
import {ErrorScreen} from '../util/error/ErrorScreen'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
import * as Toast from '../util/Toast'
import {useStores} from '../../../state'
import * as apilib from '../../../state/lib/api'
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {useStores} from 'state/index'
import {
SuggestedActorsViewModel,
SuggestedActor,
} from '../../../state/models/suggested-actors-view'
import {s, gradients} from '../../lib/styles'
import {usePalette} from '../../lib/hooks/usePalette'
} from 'state/models/suggested-actors-view'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
export const SuggestedFollows = observer(
({
onNoSuggestions,
asLinks,
}: {
onNoSuggestions?: () => void
asLinks?: boolean
}) => {
({onNoSuggestions}: {onNoSuggestions?: () => void}) => {
const pal = usePalette('default')
const store = useStores()
const [follows, setFollows] = useState<Record<string, string>>({})
// Using default import (React.use...) instead of named import (use...) to be able to mock store's data in jest environment
const view = React.useMemo<SuggestedActorsViewModel>(
() => new SuggestedActorsViewModel(store),
[store],
)
useEffect(() => {
React.useEffect(() => {
view
.loadMore()
.catch((err: any) =>
@ -53,7 +30,7 @@ export const SuggestedFollows = observer(
)
}, [view, store.log])
useEffect(() => {
React.useEffect(() => {
if (!view.isLoading && !view.hasError && !view.hasContent) {
onNoSuggestions?.()
}
@ -74,46 +51,16 @@ export const SuggestedFollows = observer(
)
}
const onPressFollow = async (item: SuggestedActor) => {
try {
const res = await apilib.follow(store, item.did, item.declaration.cid)
setFollows({[item.did]: res.uri, ...follows})
} catch (e: any) {
store.log.error('Failed fo create follow', e)
Toast.show('An issue occurred, please try again.')
}
}
const onPressUnfollow = async (item: SuggestedActor) => {
try {
await apilib.unfollow(store, follows[item.did])
setFollows(_omit(follows, [item.did]))
} catch (e: any) {
store.log.error('Failed fo delete follow', e)
Toast.show('An issue occurred, please try again.')
}
}
const renderItem = ({item}: {item: SuggestedActor}) => {
if (asLinks) {
return (
<Link
href={`/profile/${item.handle}`}
title={item.displayName || item.handle}>
<User
item={item}
follow={follows[item.did]}
onPressFollow={onPressFollow}
onPressUnfollow={onPressUnfollow}
/>
</Link>
)
}
return (
<User
item={item}
follow={follows[item.did]}
onPressFollow={onPressFollow}
onPressUnfollow={onPressUnfollow}
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
description={item.description}
/>
)
}
@ -146,7 +93,6 @@ export const SuggestedFollows = observer(
</View>
)}
contentContainerStyle={s.contentContainer}
style={s.flex1}
/>
</View>
)}
@ -155,128 +101,16 @@ export const SuggestedFollows = observer(
},
)
const User = ({
item,
follow,
onPressFollow,
onPressUnfollow,
}: {
item: SuggestedActor
follow: string | undefined
onPressFollow: (item: SuggestedActor) => void
onPressUnfollow: (item: SuggestedActor) => void
}) => {
const pal = usePalette('default')
return (
<View style={[styles.actor, pal.view, pal.border]}>
<View style={styles.actorMeta}>
<View style={styles.actorAvi}>
<UserAvatar
size={40}
displayName={item.displayName}
handle={item.handle}
avatar={item.avatar}
/>
</View>
<View style={styles.actorContent}>
<Text type="title-sm" style={pal.text} numberOfLines={1}>
{item.displayName || item.handle}
</Text>
<Text style={pal.textLight} numberOfLines={1}>
@{item.handle}
</Text>
</View>
<View style={styles.actorBtn}>
{follow ? (
<TouchableOpacity onPress={() => onPressUnfollow(item)}>
<View style={[styles.btn, styles.secondaryBtn, pal.btn]}>
<Text type="button" style={pal.text}>
Unfollow
</Text>
</View>
</TouchableOpacity>
) : (
<TouchableOpacity onPress={() => onPressFollow(item)}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn, styles.gradientBtn]}>
<FontAwesomeIcon
icon="plus"
style={[s.white as FontAwesomeIconStyle, s.mr5]}
size={15}
/>
<Text style={[s.white, s.fw600, s.f15]}>Follow</Text>
</LinearGradient>
</TouchableOpacity>
)}
</View>
</View>
{item.description ? (
<View style={styles.actorDetails}>
<Text style={pal.text} numberOfLines={4}>
{item.description}
</Text>
</View>
) : undefined}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
height: '100%',
},
suggestionsContainer: {
flex: 1,
height: '100%',
},
footer: {
height: 200,
paddingTop: 20,
},
actor: {
borderTopWidth: 1,
},
actorMeta: {
flexDirection: 'row',
},
actorAvi: {
width: 60,
paddingLeft: 10,
paddingTop: 10,
paddingBottom: 10,
},
actorContent: {
flex: 1,
paddingRight: 10,
paddingTop: 10,
},
actorBtn: {
paddingRight: 10,
paddingTop: 10,
},
actorDetails: {
paddingLeft: 60,
paddingRight: 10,
paddingBottom: 10,
},
gradientBtn: {
paddingHorizontal: 24,
paddingVertical: 6,
},
secondaryBtn: {
paddingHorizontal: 14,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 7,
borderRadius: 50,
marginLeft: 6,
},
})

View file

@ -0,0 +1,66 @@
import React from 'react'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
import {SuggestedPostsView} from 'state/models/suggested-posts-view'
import {s} from 'lib/styles'
import {FeedItem as Post} from '../posts/FeedItem'
import {Text} from '../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
export const SuggestedPosts = observer(() => {
const pal = usePalette('default')
const store = useStores()
const suggestedPostsView = React.useMemo<SuggestedPostsView>(
() => new SuggestedPostsView(store),
[store],
)
React.useEffect(() => {
if (!suggestedPostsView.hasLoaded) {
suggestedPostsView.setup()
}
}, [store, suggestedPostsView])
return (
<>
{(suggestedPostsView.hasContent || suggestedPostsView.isLoading) && (
<Text type="title" style={[styles.heading, pal.text]}>
Recently, on Bluesky...
</Text>
)}
{suggestedPostsView.hasContent && (
<>
<View style={[pal.border, styles.bottomBorder]}>
{suggestedPostsView.posts.map(item => (
<Post item={item} key={item._reactKey} />
))}
</View>
</>
)}
{suggestedPostsView.isLoading && (
<View style={s.mt10}>
<ActivityIndicator />
</View>
)}
</>
)
})
const styles = StyleSheet.create({
heading: {
fontWeight: 'bold',
paddingHorizontal: 12,
paddingTop: 16,
paddingBottom: 8,
},
bottomBorder: {
borderBottomWidth: 1,
},
loadMore: {
paddingLeft: 12,
paddingVertical: 10,
},
})

View file

@ -0,0 +1,89 @@
import React from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {observer} from 'mobx-react-lite'
import {useStores} from 'state/index'
import {SuggestedActorsViewModel} from 'state/models/suggested-actors-view'
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {Text} from '../util/text/Text'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
export const WhoToFollow = observer(() => {
const pal = usePalette('default')
const store = useStores()
const suggestedActorsView = React.useMemo<SuggestedActorsViewModel>(
() => new SuggestedActorsViewModel(store, {pageSize: 5}),
[store],
)
React.useEffect(() => {
suggestedActorsView.loadMore(true)
}, [store, suggestedActorsView])
const onPressLoadMoreSuggestedActors = () => {
suggestedActorsView.loadMore()
}
return (
<>
{(suggestedActorsView.hasContent || suggestedActorsView.isLoading) && (
<Text type="title" style={[styles.heading, pal.text]}>
Who to follow
</Text>
)}
{suggestedActorsView.hasContent && (
<>
<View style={[pal.border, styles.bottomBorder]}>
{suggestedActorsView.suggestions.map(item => (
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
description={item.description}
/>
))}
</View>
{!suggestedActorsView.isLoading && suggestedActorsView.hasMore && (
<TouchableOpacity
onPress={onPressLoadMoreSuggestedActors}
style={styles.loadMore}>
<Text type="lg" style={pal.link}>
Show more
</Text>
</TouchableOpacity>
)}
</>
)}
{suggestedActorsView.isLoading && (
<View style={s.mt10}>
<ActivityIndicator />
</View>
)}
</>
)
})
const styles = StyleSheet.create({
heading: {
fontWeight: 'bold',
paddingHorizontal: 12,
paddingTop: 16,
paddingBottom: 8,
},
bottomBorder: {
borderBottomWidth: 1,
},
loadMore: {
paddingLeft: 16,
paddingVertical: 12,
},
})

View file

@ -86,12 +86,18 @@ function ImageViewing({
[toggleBarsVisible],
)
const onLayout = useCallback(() => {
if (imageIndex) {
imageList.current?.scrollToIndex({index: imageIndex, animated: false})
}
}, [imageList, imageIndex])
if (!visible) {
return null
}
return (
<View style={styles.screen}>
<View style={styles.screen} onLayout={onLayout}>
<Modal />
<View style={[styles.container, {opacity, backgroundColor}]}>
<Animated.View style={[styles.header, {transform: headerTransform}]}>
@ -108,12 +114,8 @@ function ImageViewing({
data={images}
horizontal
pagingEnabled
windowSize={2}
initialNumToRender={1}
maxToRenderPerBatch={1}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
initialScrollIndex={imageIndex}
getItem={(_, index) => images[index]}
getItemCount={() => images.length}
getItemLayout={(_, index) => ({

View file

@ -2,9 +2,9 @@ import React from 'react'
import {View} from 'react-native'
import {observer} from 'mobx-react-lite'
import ImageView from './ImageViewing'
import {useStores} from '../../../state'
import * as models from '../../../state/models/shell-ui'
import {saveImageModal} from '../../../lib/images'
import {useStores} from 'state/index'
import * as models from 'state/models/shell-ui'
import {saveImageModal} from 'lib/images'
import {ImageSource} from './ImageViewing/@types'
export const Lightbox = observer(function Lightbox() {

View file

@ -8,9 +8,9 @@ import {
} from 'react-native'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useStores} from '../../../state'
import * as models from '../../../state/models/shell-ui'
import {colors} from '../../lib/styles'
import {useStores} from 'state/index'
import * as models from 'state/models/shell-ui'
import {colors} from 'lib/styles'
interface Img {
uri: string

View file

@ -15,24 +15,22 @@ import {
} from '@fortawesome/react-native-fontawesome'
import {ComAtprotoAccountCreate} from '@atproto/api'
import * as EmailValidator from 'email-validator'
// import {useAnalytics} from '@segment/analytics-react-native' TODO
import {useAnalytics} from 'lib/analytics'
import {LogoTextHero} from './Logo'
import {Picker} from '../util/Picker'
import {TextLink} from '../util/Link'
import {Text} from '../util/text/Text'
import {s, colors} from '../../lib/styles'
import {
makeValidHandle,
createFullHandle,
toNiceDomain,
} from '../../../lib/strings'
import {useStores, DEFAULT_SERVICE} from '../../../state'
import {ServiceDescription} from '../../../state/models/session'
import {ServerInputModal} from '../../../state/models/shell-ui'
import {usePalette} from '../../lib/hooks/usePalette'
import {s, colors} from 'lib/styles'
import {makeValidHandle, createFullHandle} from 'lib/strings/handles'
import {toNiceDomain} from 'lib/strings/url-helpers'
import {useStores, DEFAULT_SERVICE} from 'state/index'
import {ServiceDescription} from 'state/models/session'
import {ServerInputModal} from 'state/models/shell-ui'
import {usePalette} from 'lib/hooks/usePalette'
import {cleanError} from 'lib/strings/errors'
export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
// const {track} = useAnalytics() TODO
const {track, screen} = useAnalytics()
const pal = usePalette('default')
const store = useStores()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
@ -49,6 +47,10 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
const [handle, setHandle] = useState<string>('')
const [is13, setIs13] = useState<boolean>(false)
useEffect(() => {
screen('CreateAccount')
}, [screen])
useEffect(() => {
let aborted = false
setError('')
@ -109,7 +111,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
password,
inviteCode,
})
// track('Create Account') TODO
track('Create Account')
} catch (e: any) {
let errMsg = e.toString()
if (e instanceof ComAtprotoAccountCreate.InvalidInviteCodeError) {
@ -118,7 +120,7 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
}
store.log.error('Failed to create account', e)
setIsProcessing(false)
setError(errMsg.replace(/^Error:/, ''))
setError(cleanError(errMsg))
}
}

View file

@ -1,7 +1,7 @@
import React from 'react'
import {StyleSheet} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {s, gradients} from '../../lib/styles'
import {s, gradients} from 'lib/styles'
import {Text} from '../util/text/Text'
export const LogoTextHero = () => {

View file

@ -13,19 +13,21 @@ import {
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import * as EmailValidator from 'email-validator'
import {sessionClient as AtpApi, SessionServiceClient} from '@atproto/api'
// import {useAnalytics} from '@segment/analytics-react-native' TODO
import AtpAgent from '@atproto/api'
import {useAnalytics} from 'lib/analytics'
import {LogoTextHero} from './Logo'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
import {s, colors} from '../../lib/styles'
import {createFullHandle, toNiceDomain} from '../../../lib/strings'
import {useStores, RootStoreModel, DEFAULT_SERVICE} from '../../../state'
import {ServiceDescription} from '../../../state/models/session'
import {ServerInputModal} from '../../../state/models/shell-ui'
import {AccountData} from '../../../state/models/session'
import {isNetworkError} from '../../../lib/errors'
import {usePalette} from '../../lib/hooks/usePalette'
import {s, colors} from 'lib/styles'
import {createFullHandle} from 'lib/strings/handles'
import {toNiceDomain} from 'lib/strings/url-helpers'
import {useStores, RootStoreModel, DEFAULT_SERVICE} from 'state/index'
import {ServiceDescription} from 'state/models/session'
import {ServerInputModal} from 'state/models/shell-ui'
import {AccountData} from 'state/models/session'
import {isNetworkError} from 'lib/strings/errors'
import {usePalette} from 'lib/hooks/usePalette'
import {cleanError} from 'lib/strings/errors'
enum Forms {
Login,
@ -38,6 +40,7 @@ enum Forms {
export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
const pal = usePalette('default')
const store = useStores()
const {track} = useAnalytics()
const [error, setError] = useState<string>('')
const [retryDescribeTrigger, setRetryDescribeTrigger] = useState<any>({})
const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE)
@ -91,6 +94,10 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
}, [store.session, store.log, serviceUrl, retryDescribeTrigger])
const onPressRetryConnect = () => setRetryDescribeTrigger({})
const onPressForgotPassword = () => {
track('Signin:PressedForgotPassword')
setCurrentForm(Forms.ForgotPassword)
}
return (
<KeyboardAvoidingView testID="signIn" behavior="padding" style={[pal.view]}>
@ -104,7 +111,7 @@ export const Signin = ({onPressBack}: {onPressBack: () => void}) => {
setError={setError}
setServiceUrl={setServiceUrl}
onPressBack={onPressBack}
onPressForgotPassword={gotoForm(Forms.ForgotPassword)}
onPressForgotPassword={onPressForgotPassword}
onPressRetryConnect={onPressRetryConnect}
/>
) : undefined}
@ -153,15 +160,19 @@ const ChooseAccountForm = ({
onSelectAccount: (account?: AccountData) => void
onPressBack: () => void
}) => {
// const {track} = useAnalytics() TODO
const {track, screen} = useAnalytics()
const pal = usePalette('default')
const [isProcessing, setIsProcessing] = React.useState(false)
// React.useEffect(() => {
screen('Choose Account')
// }, [screen])
const onTryAccount = async (account: AccountData) => {
if (account.accessJwt && account.refreshJwt) {
setIsProcessing(true)
if (await store.session.resumeSession(account)) {
// track('Sign In', {resumedSession: true}) TODO
track('Sign In', {resumedSession: true})
setIsProcessing(false)
return
}
@ -261,15 +272,16 @@ const LoginForm = ({
onPressBack: () => void
onPressForgotPassword: () => void
}) => {
// const {track} = useAnalytics() TODO
const {track} = useAnalytics()
const pal = usePalette('default')
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [handle, setHandle] = useState<string>(initialHandle)
const [identifier, setIdentifier] = useState<string>(initialHandle)
const [password, setPassword] = useState<string>('')
const onPressSelectService = () => {
store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl))
Keyboard.dismiss()
track('Signin:PressedSelectService')
}
const onPressNext = async () => {
@ -278,20 +290,21 @@ const LoginForm = ({
try {
// try to guess the handle if the user just gave their own username
let fullHandle = handle
let fullIdent = identifier
if (
!identifier.includes('@') && // not an email
serviceDescription &&
serviceDescription.availableUserDomains.length > 0
) {
let matched = false
for (const domain of serviceDescription.availableUserDomains) {
if (fullHandle.endsWith(domain)) {
if (fullIdent.endsWith(domain)) {
matched = true
}
}
if (!matched) {
fullHandle = createFullHandle(
handle,
fullIdent = createFullHandle(
identifier,
serviceDescription.availableUserDomains[0],
)
}
@ -299,10 +312,10 @@ const LoginForm = ({
await store.session.login({
service: serviceUrl,
handle: fullHandle,
identifier: fullIdent,
password,
})
// track('Sign In', {resumedSession: false}) TODO
track('Sign In', {resumedSession: false})
} catch (e: any) {
const errMsg = e.toString()
store.log.warn('Failed to login', e)
@ -314,12 +327,12 @@ const LoginForm = ({
'Unable to contact your service. Please check your Internet connection.',
)
} else {
setError(errMsg.replace(/^Error:/, ''))
setError(cleanError(errMsg))
}
}
}
const isReady = !!serviceDescription && !!handle && !!password
const isReady = !!serviceDescription && !!identifier && !!password
return (
<View testID="loginForm">
<LogoTextHero />
@ -361,13 +374,13 @@ const LoginForm = ({
<TextInput
testID="loginUsernameInput"
style={[pal.text, styles.textInput]}
placeholder="Username"
placeholder="Username or email address"
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoFocus
autoCorrect={false}
value={handle}
onChangeText={str => setHandle((str || '').toLowerCase())}
value={identifier}
onChangeText={str => setIdentifier((str || '').toLowerCase())}
editable={!isProcessing}
/>
</View>
@ -464,6 +477,11 @@ const ForgotPasswordForm = ({
const pal = usePalette('default')
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [email, setEmail] = useState<string>('')
const {screen} = useAnalytics()
// useEffect(() => {
screen('Signin:ForgotPassword')
// }, [screen])
const onPressSelectService = () => {
store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl))
@ -478,8 +496,8 @@ const ForgotPasswordForm = ({
setIsProcessing(true)
try {
const api = AtpApi.service(serviceUrl) as SessionServiceClient
await api.com.atproto.account.requestPasswordReset({email})
const agent = new AtpAgent({service: serviceUrl})
await agent.api.com.atproto.account.requestPasswordReset({email})
onEmailSent()
} catch (e: any) {
const errMsg = e.toString()
@ -490,7 +508,7 @@ const ForgotPasswordForm = ({
'Unable to contact your service. Please check your Internet connection.',
)
} else {
setError(errMsg.replace(/^Error:/, ''))
setError(cleanError(errMsg))
}
}
}
@ -604,6 +622,12 @@ const SetNewPasswordForm = ({
onPasswordSet: () => void
}) => {
const pal = usePalette('default')
const {screen} = useAnalytics()
// useEffect(() => {
screen('Signin:SetNewPasswordForm')
// }, [screen])
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [resetCode, setResetCode] = useState<string>('')
const [password, setPassword] = useState<string>('')
@ -613,8 +637,11 @@ const SetNewPasswordForm = ({
setIsProcessing(true)
try {
const api = AtpApi.service(serviceUrl) as SessionServiceClient
await api.com.atproto.account.resetPassword({token: resetCode, password})
const agent = new AtpAgent({service: serviceUrl})
await agent.api.com.atproto.account.resetPassword({
token: resetCode,
password,
})
onPasswordSet()
} catch (e: any) {
const errMsg = e.toString()
@ -625,7 +652,7 @@ const SetNewPasswordForm = ({
'Unable to contact your service. Please check your Internet connection.',
)
} else {
setError(errMsg.replace(/^Error:/, ''))
setError(cleanError(errMsg))
}
}
}
@ -726,6 +753,12 @@ const SetNewPasswordForm = ({
}
const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => {
const {screen} = useAnalytics()
// useEffect(() => {
screen('Signin:PasswordUpdatedForm')
// }, [screen])
const pal = usePalette('default')
return (
<>

View file

@ -7,9 +7,10 @@ import {
} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {Text} from '../util/text/Text'
import {useStores} from '../../../state'
import {s, colors, gradients} from '../../lib/styles'
import {useStores} from 'state/index'
import {s, colors, gradients} from 'lib/styles'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {cleanError} from 'lib/strings/errors'
export const snapPoints = ['50%']
@ -33,7 +34,7 @@ export function Component({
store.shell.closeModal()
return
} catch (e: any) {
setError(e.toString())
setError(cleanError(e))
setIsProcessing(false)
}
}

View file

@ -0,0 +1,210 @@
import React from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {BottomSheetTextInput} from '@gorhom/bottom-sheet'
import LinearGradient from 'react-native-linear-gradient'
import * as Toast from '../util/Toast'
import {Text} from '../util/text/Text'
import {useStores} from 'state/index'
import {s, colors, gradients} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {cleanError} from 'lib/strings/errors'
export const snapPoints = ['60%']
export function Component({}: {}) {
const pal = usePalette('default')
const store = useStores()
const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false)
const [confirmCode, setConfirmCode] = React.useState<string>('')
const [password, setPassword] = React.useState<string>('')
const [isProcessing, setIsProcessing] = React.useState<boolean>(false)
const [error, setError] = React.useState<string>('')
const onPressSendEmail = async () => {
setError('')
setIsProcessing(true)
try {
await store.api.com.atproto.account.requestDelete()
setIsEmailSent(true)
} catch (e: any) {
setError(cleanError(e))
}
setIsProcessing(false)
}
const onPressConfirmDelete = async () => {
setError('')
setIsProcessing(true)
try {
await store.api.com.atproto.account.delete({
did: store.me.did,
password,
token: confirmCode,
})
Toast.show('Your account has been deleted')
store.nav.tab.fixedTabReset()
store.session.clear()
store.shell.closeModal()
} catch (e: any) {
setError(cleanError(e))
}
setIsProcessing(false)
}
const onCancel = () => {
store.shell.closeModal()
}
return (
<View
style={[styles.container, {backgroundColor: pal.colors.backgroundLight}]}>
<View style={[styles.innerContainer, pal.view]}>
<Text type="title-xl" style={[styles.title, pal.text]}>
Delete account
</Text>
{!isEmailSent ? (
<>
<Text type="lg" style={[styles.description, pal.text]}>
For security reasons, we'll need to send a confirmation code to
your email.
</Text>
{error ? (
<View style={s.mt10}>
<ErrorMessage message={error} />
</View>
) : undefined}
{isProcessing ? (
<View style={[styles.btn, s.mt10]}>
<ActivityIndicator />
</View>
) : (
<>
<TouchableOpacity
style={styles.mt20}
onPress={onPressSendEmail}>
<LinearGradient
colors={[
gradients.blueLight.start,
gradients.blueLight.end,
]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text type="button-lg" style={[s.white, s.bold]}>
Send email
</Text>
</LinearGradient>
</TouchableOpacity>
<TouchableOpacity
style={[styles.btn, s.mt10]}
onPress={onCancel}>
<Text type="button-lg" style={pal.textLight}>
Cancel
</Text>
</TouchableOpacity>
</>
)}
</>
) : (
<>
<Text type="lg" style={styles.description}>
Check your inbox for an email with the confirmation code to enter
below:
</Text>
<BottomSheetTextInput
style={[styles.textInput, pal.borderDark, pal.text, styles.mb20]}
placeholder="Confirmation code"
placeholderTextColor={pal.textLight.color}
value={confirmCode}
onChangeText={setConfirmCode}
/>
<Text type="lg" style={styles.description}>
Please enter your password as well:
</Text>
<BottomSheetTextInput
style={[styles.textInput, pal.borderDark, pal.text]}
placeholder="Password"
placeholderTextColor={pal.textLight.color}
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{error ? (
<View style={styles.mt20}>
<ErrorMessage message={error} />
</View>
) : undefined}
{isProcessing ? (
<View style={[styles.btn, s.mt10]}>
<ActivityIndicator />
</View>
) : (
<>
<TouchableOpacity
style={[styles.btn, styles.evilBtn, styles.mt20]}
onPress={onPressConfirmDelete}>
<Text type="button-lg" style={[s.white, s.bold]}>
Delete my account
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.btn, s.mt10]}
onPress={onCancel}>
<Text type="button-lg" style={pal.textLight}>
Cancel
</Text>
</TouchableOpacity>
</>
)}
</>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
innerContainer: {
paddingBottom: 20,
},
title: {
textAlign: 'center',
marginTop: 12,
marginBottom: 12,
},
description: {
textAlign: 'center',
paddingHorizontal: 22,
marginBottom: 10,
},
mt20: {
marginTop: 20,
},
mb20: {
marginBottom: 20,
},
textInput: {
borderWidth: 1,
borderRadius: 6,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 20,
marginHorizontal: 20,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
padding: 14,
marginHorizontal: 20,
},
evilBtn: {
backgroundColor: colors.red4,
},
})

View file

@ -11,18 +11,17 @@ import {ScrollView, TextInput} from './util'
import {PickedMedia} from '../util/images/image-crop-picker/ImageCropPicker'
import {Text} from '../util/text/Text'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {useStores} from '../../../state'
import {ProfileViewModel} from '../../../state/models/profile-view'
import {s, colors, gradients} from '../../lib/styles'
import {
enforceLen,
MAX_DISPLAY_NAME,
MAX_DESCRIPTION,
} from '../../../lib/strings'
import {isNetworkError} from '../../../lib/errors'
import {compressIfNeeded} from '../../../lib/images'
import {useStores} from 'state/index'
import {ProfileViewModel} from 'state/models/profile-view'
import {s, colors, gradients} from 'lib/styles'
import {enforceLen} from 'lib/strings/helpers'
import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants'
import {compressIfNeeded} from 'lib/images'
import {UserBanner} from '../util/UserBanner'
import {UserAvatar} from '../util/UserAvatar'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics'
import {cleanError, isNetworkError} from 'lib/strings/errors'
export const snapPoints = ['80%']
@ -35,6 +34,9 @@ export function Component({
}) {
const store = useStores()
const [error, setError] = useState<string>('')
const pal = usePalette('default')
const {track} = useAnalytics()
const [isProcessing, setProcessing] = useState<boolean>(false)
const [displayName, setDisplayName] = useState<string>(
profileView.displayName || '',
@ -54,24 +56,27 @@ export function Component({
store.shell.closeModal()
}
const onSelectNewAvatar = async (img: PickedMedia) => {
track('EditProfile:AvatarSelected')
try {
const finalImg = await compressIfNeeded(img, 300000)
setNewUserAvatar(finalImg)
const finalImg = await compressIfNeeded(img, 1000000)
setNewUserAvatar({mediaType: 'photo', ...finalImg})
setUserAvatar(finalImg.path)
} catch (e: any) {
setError(e.message || e.toString())
setError(cleanError(e))
}
}
const onSelectNewBanner = async (img: PickedMedia) => {
track('EditProfile:BannerSelected')
try {
const finalImg = await compressIfNeeded(img, 500000)
setNewUserBanner(finalImg)
const finalImg = await compressIfNeeded(img, 1000000)
setNewUserBanner({mediaType: 'photo', ...finalImg})
setUserBanner(finalImg.path)
} catch (e: any) {
setError(e.message || e.toString())
setError(cleanError(e))
}
}
const onPressSave = async () => {
track('EditProfile:Save')
setProcessing(true)
if (error) {
setError('')
@ -94,7 +99,7 @@ export function Component({
'Failed to save your profile. Check your internet connection and try again.',
)
} else {
setError(e.message)
setError(cleanError(e))
}
}
setProcessing(false)
@ -103,13 +108,13 @@ export function Component({
return (
<View style={s.flex1}>
<ScrollView style={styles.inner}>
<Text style={styles.title}>Edit my profile</Text>
<Text style={[styles.title, pal.text]}>Edit my profile</Text>
<View style={styles.photos}>
<UserBanner
banner={userBanner}
onSelectNewBanner={onSelectNewBanner}
/>
<View style={styles.avi}>
<View style={[styles.avi, {borderColor: pal.colors.background}]}>
<UserAvatar
size={80}
avatar={userAvatar}
@ -127,7 +132,7 @@ export function Component({
<View>
<Text style={styles.label}>Display Name</Text>
<TextInput
style={styles.textInput}
style={[styles.textInput, pal.text]}
placeholder="e.g. Alice Roberts"
placeholderTextColor={colors.gray4}
value={displayName}
@ -135,9 +140,9 @@ export function Component({
/>
</View>
<View style={s.pb10}>
<Text style={styles.label}>Description</Text>
<Text style={[styles.label, pal.text]}>Description</Text>
<TextInput
style={[styles.textArea]}
style={[styles.textArea, pal.text]}
placeholder="e.g. Artist, dog-lover, and memelord."
placeholderTextColor={colors.gray4}
multiline
@ -162,7 +167,7 @@ export function Component({
)}
<TouchableOpacity style={s.mt5} onPress={onPressCancel}>
<View style={[styles.btn]}>
<Text style={[s.black, s.bold]}>Cancel</Text>
<Text style={[s.black, s.bold, pal.text]}>Cancel</Text>
</View>
</TouchableOpacity>
</ScrollView>

View file

@ -2,23 +2,26 @@ import React, {useRef, useEffect} from 'react'
import {View} from 'react-native'
import {observer} from 'mobx-react-lite'
import BottomSheet from '@gorhom/bottom-sheet'
import {useStores} from '../../../state'
import {useStores} from 'state/index'
import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
import * as models from '../../../state/models/shell-ui'
import * as models from 'state/models/shell-ui'
import * as ConfirmModal from './Confirm'
import * as EditProfileModal from './EditProfile'
import * as ServerInputModal from './ServerInput'
import * as ReportPostModal from './ReportPost'
import * as ReportAccountModal from './ReportAccount'
import * as DeleteAccountModal from './DeleteAccount'
import {usePalette} from 'lib/hooks/usePalette'
import {StyleSheet} from 'react-native'
const CLOSED_SNAPPOINTS = ['10%']
export const Modal = observer(function Modal() {
const store = useStores()
const bottomSheetRef = useRef<BottomSheet>(null)
const pal = usePalette('default')
const onBottomSheetChange = (snapPoint: number) => {
if (snapPoint === -1) {
store.shell.closeModal()
@ -62,10 +65,21 @@ export const Modal = observer(function Modal() {
)
} else if (store.shell.activeModal?.name === 'report-post') {
snapPoints = ReportPostModal.snapPoints
element = <ReportPostModal.Component />
element = (
<ReportPostModal.Component
{...(store.shell.activeModal as models.ReportPostModal)}
/>
)
} else if (store.shell.activeModal?.name === 'report-account') {
snapPoints = ReportAccountModal.snapPoints
element = <ReportAccountModal.Component />
element = (
<ReportAccountModal.Component
{...(store.shell.activeModal as models.ReportAccountModal)}
/>
)
} else if (store.shell.activeModal?.name === 'delete-account') {
snapPoints = DeleteAccountModal.snapPoints
element = <DeleteAccountModal.Component />
} else {
element = <View />
}
@ -80,8 +94,17 @@ export const Modal = observer(function Modal() {
backdropComponent={
store.shell.isModalActive ? createCustomBackdrop(onClose) : undefined
}
handleIndicatorStyle={{backgroundColor: pal.text.color}}
handleStyle={[styles.handle, pal.view]}
onChange={onBottomSheetChange}>
{element}
</BottomSheet>
)
})
const styles = StyleSheet.create({
handle: {
borderTopLeftRadius: 10,
borderTopRightRadius: 10,
},
})

View file

@ -1,10 +1,10 @@
import React from 'react'
import {TouchableWithoutFeedback, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {useStores} from '../../../state'
import {usePalette} from '../../lib/hooks/usePalette'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import * as models from '../../../state/models/shell-ui'
import * as models from 'state/models/shell-ui'
import * as ConfirmModal from './Confirm'
import * as EditProfileModal from './EditProfile'
@ -48,9 +48,17 @@ export const Modal = observer(function Modal() {
/>
)
} else if (store.shell.activeModal?.name === 'report-post') {
element = <ReportPostModal.Component />
element = (
<ReportPostModal.Component
{...(store.shell.activeModal as models.ReportPostModal)}
/>
)
} else if (store.shell.activeModal?.name === 'report-account') {
element = <ReportAccountModal.Component />
element = (
<ReportAccountModal.Component
{...(store.shell.activeModal as models.ReportAccountModal)}
/>
)
} else if (store.shell.activeModal?.name === 'crop-image') {
element = (
<CropImageModal.Component

View file

@ -5,12 +5,15 @@ import {
TouchableOpacity,
View,
} from 'react-native'
import {ComAtprotoReportReasonType} from '@atproto/api'
import LinearGradient from 'react-native-linear-gradient'
import {useStores} from '../../../state'
import {s, colors, gradients} from '../../lib/styles'
import {useStores} from 'state/index'
import {s, colors, gradients} from 'lib/styles'
import {RadioGroup, RadioGroupItem} from '../util/forms/RadioGroup'
import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {cleanError} from 'lib/strings/errors'
const ITEMS: RadioGroupItem[] = [
{key: 'spam', label: 'Spam or excessive repeat posts'},
@ -20,7 +23,7 @@ const ITEMS: RadioGroupItem[] = [
export const snapPoints = ['50%']
export function Component() {
export function Component({did}: {did: string}) {
const store = useStores()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [error, setError] = useState<string>('')
@ -28,13 +31,30 @@ export function Component() {
const onSelectIssue = (v: string) => setIssue(v)
const onPress = async () => {
setError('')
if (!issue) {
return
}
setIsProcessing(true)
try {
// TODO
// NOTE: we should update the lexicon of reasontype to include more options -prf
let reasonType = ComAtprotoReportReasonType.OTHER
if (issue === 'spam') {
reasonType = ComAtprotoReportReasonType.SPAM
}
const reason = ITEMS.find(item => item.key === issue)?.label || ''
await store.api.com.atproto.report.create({
reasonType,
reason,
subject: {
$type: 'com.atproto.repo.repoRef',
did,
},
})
Toast.show("Thank you for your report! We'll look into it promptly.")
store.shell.closeModal()
return
} catch (e: any) {
setError(e.toString())
setError(cleanError(e))
setIsProcessing(false)
}
}

View file

@ -5,12 +5,15 @@ import {
TouchableOpacity,
View,
} from 'react-native'
import {ComAtprotoReportReasonType} from '@atproto/api'
import LinearGradient from 'react-native-linear-gradient'
import {useStores} from '../../../state'
import {s, colors, gradients} from '../../lib/styles'
import {useStores} from 'state/index'
import {s, colors, gradients} from 'lib/styles'
import {RadioGroup, RadioGroupItem} from '../util/forms/RadioGroup'
import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {cleanError} from 'lib/strings/errors'
const ITEMS: RadioGroupItem[] = [
{key: 'spam', label: 'Spam or excessive repeat posts'},
@ -21,7 +24,13 @@ const ITEMS: RadioGroupItem[] = [
export const snapPoints = ['50%']
export function Component() {
export function Component({
postUri,
postCid,
}: {
postUri: string
postCid: string
}) {
const store = useStores()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [error, setError] = useState<string>('')
@ -29,13 +38,31 @@ export function Component() {
const onSelectIssue = (v: string) => setIssue(v)
const onPress = async () => {
setError('')
if (!issue) {
return
}
setIsProcessing(true)
try {
// TODO
// NOTE: we should update the lexicon of reasontype to include more options -prf
let reasonType = ComAtprotoReportReasonType.OTHER
if (issue === 'spam') {
reasonType = ComAtprotoReportReasonType.SPAM
}
const reason = ITEMS.find(item => item.key === issue)?.label || ''
await store.api.com.atproto.report.create({
reasonType,
reason,
subject: {
$type: 'com.atproto.repo.recordRef',
uri: postUri,
cid: postCid,
},
})
Toast.show("Thank you for your report! We'll look into it promptly.")
store.shell.closeModal()
return
} catch (e: any) {
setError(e.toString())
setError(cleanError(e))
setIsProcessing(false)
}
}

View file

@ -6,14 +6,10 @@ import {
} from '@fortawesome/react-native-fontawesome'
import {ScrollView, TextInput} from './util'
import {Text} from '../util/text/Text'
import {useStores} from '../../../state'
import {s, colors} from '../../lib/styles'
import {
LOCAL_DEV_SERVICE,
STAGING_SERVICE,
PROD_SERVICE,
} from '../../../state/index'
import {LOGIN_INCLUDE_DEV_SERVERS} from '../../../build-flags'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles'
import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index'
import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
export const snapPoints = ['80%']
@ -37,6 +33,7 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) {
{LOGIN_INCLUDE_DEV_SERVERS ? (
<>
<TouchableOpacity
testID="localDevServerButton"
style={styles.btn}
onPress={() => doSelect(LOCAL_DEV_SERVICE)}>
<Text style={styles.btnText}>Local dev server</Text>

View file

@ -5,10 +5,10 @@ import {Slider} from '@miblanchard/react-native-slider'
import LinearGradient from 'react-native-linear-gradient'
import {Text} from '../../util/text/Text'
import {PickedMedia} from '../../util/images/image-crop-picker/types'
import {s, gradients} from '../../../lib/styles'
import {useStores} from '../../../../state'
import {usePalette} from '../../../lib/hooks/usePalette'
import {SquareIcon, RectWideIcon, RectTallIcon} from '../../../lib/icons'
import {s, gradients} from 'lib/styles'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {SquareIcon, RectWideIcon, RectTallIcon} from 'lib/icons'
enum AspectRatio {
Square = 'square',

View file

@ -1,31 +1,61 @@
import React from 'react'
import React, {MutableRefObject} from 'react'
import {observer} from 'mobx-react-lite'
import {StyleSheet, View} from 'react-native'
import {CenteredView, FlatList} from '../util/Views'
import {NotificationsViewModel} from '../../../state/models/notifications-view'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {NotificationsViewModel} from 'state/models/notifications-view'
import {FeedItem} from './FeedItem'
import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {EmptyState} from '../util/EmptyState'
import {OnScrollCb} from '../../lib/hooks/useOnMainScroll'
import {s} from '../../lib/styles'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {s} from 'lib/styles'
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
export const Feed = observer(function Feed({
view,
scrollElRef,
onPressTryAgain,
onScroll,
}: {
view: NotificationsViewModel
scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressTryAgain?: () => void
onScroll?: OnScrollCb
}) {
const data = React.useMemo(() => {
let feedItems
if (view.hasLoaded) {
if (view.isEmpty) {
feedItems = [EMPTY_FEED_ITEM]
} else {
feedItems = view.notifications
}
}
return feedItems
}, [view.hasLoaded, view.isEmpty, view.notifications])
const onRefresh = React.useCallback(async () => {
try {
await view.refresh()
await view.markAllRead()
} catch (err) {
view.rootStore.log.error('Failed to refresh notifications feed', err)
}
}, [view])
const onEndReached = React.useCallback(async () => {
try {
await view.loadMore()
} catch (err) {
view.rootStore.log.error('Failed to load more notifications', err)
}
}, [view])
// TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
// VirtualizedList: You have a large list that is slow to update - make sure your
// renderItem function renders components that follow React performance best practices
// like PureComponent, shouldComponentUpdate, etc
const renderItem = ({item}: {item: any}) => {
const renderItem = React.useCallback(({item}: {item: any}) => {
if (item === EMPTY_FEED_ITEM) {
return (
<EmptyState
@ -36,29 +66,20 @@ export const Feed = observer(function Feed({
)
}
return <FeedItem item={item} />
}
const onRefresh = () => {
view
.refresh()
.catch(err =>
view.rootStore.log.error('Failed to refresh notifications feed', err),
)
}
const onEndReached = () => {
view
.loadMore()
.catch(err =>
view.rootStore.log.error('Failed to load more notifications', err),
)
}
let data
if (view.hasLoaded) {
if (view.isEmpty) {
data = [EMPTY_FEED_ITEM]
} else {
data = view.notifications
}
}
}, [])
const FeedFooter = React.useCallback(
() =>
view.isLoading ? (
<View style={styles.feedFooter}>
<ActivityIndicator />
</View>
) : (
<View />
),
[view],
)
return (
<View style={s.h100pct}>
<CenteredView>
@ -72,9 +93,11 @@ export const Feed = observer(function Feed({
</CenteredView>
{data && (
<FlatList
ref={scrollElRef}
data={data}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
ListFooterComponent={FeedFooter}
refreshing={view.isRefreshing}
onRefresh={onRefresh}
onEndReached={onEndReached}
@ -87,5 +110,6 @@ export const Feed = observer(function Feed({
})
const styles = StyleSheet.create({
feedFooter: {paddingTop: 20},
emptyState: {paddingVertical: 40},
})

View file

@ -14,19 +14,19 @@ import {
FontAwesomeIconStyle,
Props,
} from '@fortawesome/react-native-fontawesome'
import {NotificationsViewItemModel} from '../../../state/models/notifications-view'
import {PostThreadViewModel} from '../../../state/models/post-thread-view'
import {s, colors} from '../../lib/styles'
import {ago, pluralize} from '../../../lib/strings'
import {HeartIconSolid} from '../../lib/icons'
import {NotificationsViewItemModel} from 'state/models/notifications-view'
import {PostThreadViewModel} from 'state/models/post-thread-view'
import {s, colors} from 'lib/styles'
import {ago} from 'lib/strings/time'
import {pluralize} from 'lib/strings/helpers'
import {HeartIconSolid} from 'lib/icons'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
import {ImageHorzList} from '../util/images/ImageHorzList'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Post} from '../post/Post'
import {Link} from '../util/Link'
import {usePalette} from '../../lib/hooks/usePalette'
import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
const MAX_AUTHORS = 5
@ -78,6 +78,10 @@ export const FeedItem = observer(function FeedItem({
}
if (item.isReply || item.isMention) {
if (item.additionalPost?.error) {
// hide errors - it doesnt help the user to show them
return <View />
}
return (
<Link href={itemHref} title={itemTitle} noFeedback>
<Post
@ -347,12 +351,13 @@ function AdditionalPostText({
additionalPost?: PostThreadViewModel
}) {
const pal = usePalette('default')
if (!additionalPost || !additionalPost.thread?.postRecord) {
if (
!additionalPost ||
!additionalPost.thread?.postRecord ||
additionalPost.error
) {
return <View />
}
if (additionalPost.error) {
return <ErrorMessage message={additionalPost.error} />
}
const text = additionalPost.thread?.postRecord.text
const images = (
additionalPost.thread.post.embed as AppBskyEmbedImages.Presented

View file

@ -14,10 +14,10 @@ import {
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {Text} from '../util/text/Text'
import {useStores} from '../../../state'
import {s} from '../../lib/styles'
import {TABS_EXPLAINER} from '../../lib/assets'
import {TABS_ENABLED} from '../../../build-flags'
import {useStores} from 'state/index'
import {s} from 'lib/styles'
import {TABS_EXPLAINER} from 'lib/assets'
import {TABS_ENABLED} from 'lib/build-flags'
const ROUTES = TABS_ENABLED
? [
@ -127,11 +127,15 @@ export const FeatureExplainer = () => {
<View />
)}
<View style={styles.footer}>
<TouchableOpacity onPress={onPressSkip}>
<TouchableOpacity
onPress={onPressSkip}
testID="onboardFeatureExplainerSkipBtn">
<Text style={[s.blue3, s.f18]}>Skip</Text>
</TouchableOpacity>
<View style={s.flex1} />
<TouchableOpacity onPress={onPressNext}>
<TouchableOpacity
onPress={onPressNext}
testID="onboardFeatureExplainerNextBtn">
<Text style={[s.blue3, s.f18]}>Next</Text>
</TouchableOpacity>
</View>

View file

@ -2,7 +2,6 @@ import React, {useState} from 'react'
import {
Animated,
Image,
SafeAreaView,
StyleSheet,
TouchableOpacity,
useWindowDimensions,
@ -15,10 +14,10 @@ import {
} from '@fortawesome/react-native-fontawesome'
import {CenteredView} from '../util/Views.web'
import {Text} from '../util/text/Text'
import {useStores} from '../../../state'
import {s, colors} from '../../lib/styles'
import {TABS_EXPLAINER} from '../../lib/assets'
import {TABS_ENABLED} from '../../../build-flags'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles'
import {TABS_EXPLAINER} from 'lib/assets'
import {TABS_ENABLED} from 'lib/build-flags'
const ROUTES = TABS_ENABLED
? [

View file

@ -3,8 +3,8 @@ import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {SuggestedFollows} from '../discover/SuggestedFollows'
import {Text} from '../util/text/Text'
import {useStores} from '../../../state'
import {s} from '../../lib/styles'
import {useStores} from 'state/index'
import {s} from 'lib/styles'
export const Follows = observer(() => {
const store = useStores()
@ -18,13 +18,15 @@ export const Follows = observer(() => {
return (
<SafeAreaView style={styles.container}>
<Text style={styles.title}>Suggested follows</Text>
<SuggestedFollows onNoSuggestions={onNoSuggestions} />
<View style={s.flex1}>
<SuggestedFollows onNoSuggestions={onNoSuggestions} />
</View>
<View style={styles.footer}>
<TouchableOpacity onPress={onPressNext}>
<TouchableOpacity onPress={onPressNext} testID="onboardFollowsSkipBtn">
<Text style={[s.blue3, s.f18]}>Skip</Text>
</TouchableOpacity>
<View style={s.flex1} />
<TouchableOpacity onPress={onPressNext}>
<TouchableOpacity onPress={onPressNext} testID="onboardFollowsNextBtn">
<Text style={[s.blue3, s.f18]}>Next</Text>
</TouchableOpacity>
</View>

View file

@ -1,11 +1,11 @@
import React from 'react'
import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native'
import {SafeAreaView, StyleSheet, TouchableOpacity} from 'react-native'
import {observer} from 'mobx-react-lite'
import {SuggestedFollows} from '../discover/SuggestedFollows'
import {CenteredView} from '../util/Views.web'
import {Text} from '../util/text/Text'
import {useStores} from '../../../state'
import {s, colors} from '../../lib/styles'
import {useStores} from 'state/index'
import {s} from 'lib/styles'
export const Follows = observer(() => {
const store = useStores()

View file

@ -5,13 +5,10 @@ import {CenteredView, FlatList} from '../util/Views'
import {
RepostedByViewModel,
RepostedByItem,
} from '../../../state/models/reposted-by-view'
import {UserAvatar} from '../util/UserAvatar'
} from 'state/models/reposted-by-view'
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {useStores} from '../../../state'
import {s, colors} from '../../lib/styles'
import {useStores} from 'state/index'
export const PostRepostedBy = observer(function PostRepostedBy({
uri,
@ -62,7 +59,15 @@ export const PostRepostedBy = observer(function PostRepostedBy({
// loaded
// =
const renderItem = ({item}: {item: RepostedByItem}) => (
<RepostedByItemCom item={item} />
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
isFollowedBy={!!item.viewer?.followedBy}
/>
)
return (
<FlatList
@ -83,57 +88,7 @@ export const PostRepostedBy = observer(function PostRepostedBy({
)
})
const RepostedByItemCom = ({item}: {item: RepostedByItem}) => {
return (
<Link
style={styles.outer}
href={`/profile/${item.handle}`}
title={item.handle}
noFeedback>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar
size={40}
displayName={item.displayName}
handle={item.handle}
avatar={item.avatar}
/>
</View>
<View style={styles.layoutContent}>
<Text style={[s.f15, s.bold]}>{item.displayName || item.handle}</Text>
<Text style={[s.f14, s.gray5]}>@{item.handle}</Text>
</View>
</View>
</Link>
)
}
const styles = StyleSheet.create({
outer: {
marginTop: 1,
backgroundColor: colors.white,
},
layout: {
flexDirection: 'row',
},
layoutAvi: {
width: 60,
paddingLeft: 10,
paddingTop: 10,
paddingBottom: 10,
},
avi: {
width: 40,
height: 40,
borderRadius: 20,
resizeMode: 'cover',
},
layoutContent: {
flex: 1,
paddingRight: 10,
paddingTop: 10,
paddingBottom: 10,
},
footer: {
height: 200,
paddingTop: 20,

View file

@ -1,14 +1,14 @@
import React, {useRef} from 'react'
import {observer} from 'mobx-react-lite'
import {ActivityIndicator, View} from 'react-native'
import {ActivityIndicator} from 'react-native'
import {CenteredView, FlatList} from '../util/Views'
import {
PostThreadViewModel,
PostThreadViewPostModel,
} from '../../../state/models/post-thread-view'
} from 'state/models/post-thread-view'
import {PostThreadItem} from './PostThreadItem'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {s} from '../../lib/styles'
import {s} from 'lib/styles'
export const PostThread = observer(function PostThread({
uri,
@ -18,15 +18,24 @@ export const PostThread = observer(function PostThread({
view: PostThreadViewModel
}) {
const ref = useRef<FlatList>(null)
const posts = view.thread ? Array.from(flattenThread(view.thread)) : []
const onRefresh = () => {
view
?.refresh()
.catch(err =>
view.rootStore.log.error('Failed to refresh posts thread', err),
)
}
const onLayout = () => {
const [isRefreshing, setIsRefreshing] = React.useState(false)
const posts = React.useMemo(
() => (view.thread ? Array.from(flattenThread(view.thread)) : []),
[view.thread],
)
// events
// =
const onRefresh = React.useCallback(async () => {
setIsRefreshing(true)
try {
view?.refresh()
} catch (err) {
view.rootStore.log.error('Failed to refresh posts thread', err)
}
setIsRefreshing(false)
}, [view, setIsRefreshing])
const onLayout = React.useCallback(() => {
const index = posts.findIndex(post => post._isHighlightedPost)
if (index !== -1) {
ref.current?.scrollToIndex({
@ -35,17 +44,20 @@ export const PostThread = observer(function PostThread({
viewOffset: 40,
})
}
}
const onScrollToIndexFailed = (info: {
index: number
highestMeasuredFrameIndex: number
averageItemLength: number
}) => {
ref.current?.scrollToOffset({
animated: false,
offset: info.averageItemLength * info.index,
})
}
}, [posts, ref])
const onScrollToIndexFailed = React.useCallback(
(info: {
index: number
highestMeasuredFrameIndex: number
averageItemLength: number
}) => {
ref.current?.scrollToOffset({
animated: false,
offset: info.averageItemLength * info.index,
})
},
[ref],
)
// loading
// =
@ -76,9 +88,10 @@ export const PostThread = observer(function PostThread({
<FlatList
ref={ref}
data={posts}
initialNumToRender={posts.length}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
refreshing={view.isRefreshing}
refreshing={isRefreshing}
onRefresh={onRefresh}
onLayout={onLayout}
onScrollToIndexFailed={onScrollToIndexFailed}

View file

@ -1,4 +1,4 @@
import React, {useMemo, useState} from 'react'
import React from 'react'
import {observer} from 'mobx-react-lite'
import {StyleSheet, View} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
@ -7,22 +7,23 @@ import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {PostThreadViewPostModel} from '../../../state/models/post-thread-view'
import {PostThreadViewPostModel} from 'state/models/post-thread-view'
import {Link} from '../util/Link'
import {RichText} from '../util/text/RichText'
import {Text} from '../util/text/Text'
import {PostDropdownBtn} from '../util/forms/DropdownButton'
import * as Toast from '../util/Toast'
import {UserAvatar} from '../util/UserAvatar'
import {s} from '../../lib/styles'
import {ago, pluralize} from '../../../lib/strings'
import {useStores} from '../../../state'
import {s} from 'lib/styles'
import {ago} from 'lib/strings/time'
import {pluralize} from 'lib/strings/helpers'
import {useStores} from 'state/index'
import {PostMeta} from '../util/PostMeta'
import {PostEmbeds} from '../util/PostEmbeds'
import {PostCtrls} from '../util/PostCtrls'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {ComposePrompt} from '../composer/Prompt'
import {usePalette} from '../../lib/hooks/usePalette'
import {usePalette} from 'lib/hooks/usePalette'
const PARENT_REPLY_LINE_LENGTH = 8
@ -35,29 +36,31 @@ export const PostThreadItem = observer(function PostThreadItem({
}) {
const pal = usePalette('default')
const store = useStores()
const [deleted, setDeleted] = useState(false)
const [deleted, setDeleted] = React.useState(false)
const record = item.postRecord
const hasEngagement = item.post.upvoteCount || item.post.repostCount
const itemHref = useMemo(() => {
const itemUri = item.post.uri
const itemCid = item.post.cid
const itemHref = React.useMemo(() => {
const urip = new AtUri(item.post.uri)
return `/profile/${item.post.author.handle}/post/${urip.rkey}`
}, [item.post.uri, item.post.author.handle])
const itemTitle = `Post by ${item.post.author.handle}`
const authorHref = `/profile/${item.post.author.handle}`
const authorTitle = item.post.author.handle
const upvotesHref = useMemo(() => {
const upvotesHref = React.useMemo(() => {
const urip = new AtUri(item.post.uri)
return `/profile/${item.post.author.handle}/post/${urip.rkey}/upvoted-by`
}, [item.post.uri, item.post.author.handle])
const upvotesTitle = 'Likes on this post'
const repostsHref = useMemo(() => {
const repostsHref = React.useMemo(() => {
const urip = new AtUri(item.post.uri)
return `/profile/${item.post.author.handle}/post/${urip.rkey}/reposted-by`
}, [item.post.uri, item.post.author.handle])
const repostsTitle = 'Reposts of this post'
const onPressReply = () => {
const onPressReply = React.useCallback(() => {
store.shell.openComposer({
replyTo: {
uri: item.post.uri,
@ -71,22 +74,22 @@ export const PostThreadItem = observer(function PostThreadItem({
},
onPost: onPostReply,
})
}
const onPressToggleRepost = () => {
item
}, [store, item, record, onPostReply])
const onPressToggleRepost = React.useCallback(() => {
return item
.toggleRepost()
.catch(e => store.log.error('Failed to toggle repost', e))
}
const onPressToggleUpvote = () => {
item
}, [item, store])
const onPressToggleUpvote = React.useCallback(() => {
return item
.toggleUpvote()
.catch(e => store.log.error('Failed to toggle upvote', e))
}
const onCopyPostText = () => {
}, [item, store])
const onCopyPostText = React.useCallback(() => {
Clipboard.setString(record?.text || '')
Toast.show('Copied to clipboard')
}
const onDeletePost = () => {
}, [record])
const onDeletePost = React.useCallback(() => {
item.delete().then(
() => {
setDeleted(true)
@ -97,7 +100,7 @@ export const PostThreadItem = observer(function PostThreadItem({
Toast.show('Failed to delete post, please try again')
},
)
}
}, [item, store])
if (!record) {
return <ErrorMessage message="Invalid or unsupported post record" />
@ -154,6 +157,8 @@ export const PostThreadItem = observer(function PostThreadItem({
<View style={s.flex1} />
<PostDropdownBtn
style={styles.metaItem}
itemUri={itemUri}
itemCid={itemCid}
itemHref={itemHref}
itemTitle={itemTitle}
isAuthor={item.post.author.did === store.me.did}
@ -179,7 +184,7 @@ export const PostThreadItem = observer(function PostThreadItem({
</View>
</View>
<View style={[s.pl10, s.pr10, s.pb10]}>
{record.text ? (
{item.richText?.text ? (
<View
style={[
styles.postTextContainer,
@ -187,8 +192,7 @@ export const PostThreadItem = observer(function PostThreadItem({
]}>
<RichText
type="post-text-lg"
text={record.text}
entities={record.entities}
richText={item.richText}
lineHeight={1.3}
/>
</View>
@ -233,6 +237,8 @@ export const PostThreadItem = observer(function PostThreadItem({
<View style={[s.pl10, s.pb5]}>
<PostCtrls
big
itemUri={itemUri}
itemCid={itemCid}
itemHref={itemHref}
itemTitle={itemTitle}
isAuthor={item.post.author.did === store.me.did}
@ -301,12 +307,11 @@ export const PostThreadItem = observer(function PostThreadItem({
<FontAwesomeIcon icon={['far', 'eye-slash']} style={s.mr2} />
<Text type="sm">This post is by a muted account.</Text>
</View>
) : record.text ? (
) : item.richText?.text ? (
<View style={styles.postTextContainer}>
<RichText
type="post-text"
text={record.text}
entities={record.entities}
richText={item.richText}
style={pal.text}
lineHeight={1.3}
/>
@ -314,6 +319,8 @@ export const PostThreadItem = observer(function PostThreadItem({
) : undefined}
<PostEmbeds embed={item.post.embed} style={s.mb10} />
<PostCtrls
itemUri={itemUri}
itemCid={itemCid}
itemHref={itemHref}
itemTitle={itemTitle}
isAuthor={item.post.author.did === store.me.did}
@ -341,7 +348,12 @@ export const PostThreadItem = observer(function PostThreadItem({
href={itemHref}
title={itemTitle}
noFeedback>
<Text style={pal.link}>Load more</Text>
<Text style={pal.link}>Continue thread...</Text>
<FontAwesomeIcon
icon="angle-right"
style={pal.link as FontAwesomeIconStyle}
size={18}
/>
</Link>
) : undefined}
</>
@ -433,8 +445,12 @@ const styles = StyleSheet.create({
marginRight: 10,
},
loadMore: {
flexDirection: 'row',
justifyContent: 'space-between',
borderTopWidth: 1,
paddingLeft: 28,
paddingLeft: 80,
paddingRight: 20,
paddingVertical: 10,
marginBottom: 8,
},
})

View file

@ -2,14 +2,10 @@ import React, {useEffect} from 'react'
import {observer} from 'mobx-react-lite'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {CenteredView, FlatList} from '../util/Views'
import {VotesViewModel, VoteItem} from '../../../state/models/votes-view'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {VotesViewModel, VoteItem} from 'state/models/votes-view'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {UserAvatar} from '../util/UserAvatar'
import {useStores} from '../../../state'
import {s} from '../../lib/styles'
import {usePalette} from '../../lib/hooks/usePalette'
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {useStores} from 'state/index'
export const PostVotedBy = observer(function PostVotedBy({
uri,
@ -57,7 +53,17 @@ export const PostVotedBy = observer(function PostVotedBy({
// loaded
// =
const renderItem = ({item}: {item: VoteItem}) => <LikedByItem item={item} />
const renderItem = ({item}: {item: VoteItem}) => (
<ProfileCardWithFollowBtn
key={item.actor.did}
did={item.actor.did}
declarationCid={item.actor.declaration.cid}
handle={item.actor.handle}
displayName={item.actor.displayName}
avatar={item.actor.avatar}
isFollowedBy={!!item.actor.viewer?.followedBy}
/>
)
return (
<FlatList
data={view.votes}
@ -77,62 +83,7 @@ export const PostVotedBy = observer(function PostVotedBy({
)
})
const LikedByItem = ({item}: {item: VoteItem}) => {
const pal = usePalette('default')
return (
<Link
style={[styles.outer, pal.view]}
href={`/profile/${item.actor.handle}`}
title={item.actor.handle}
noFeedback>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar
size={40}
displayName={item.actor.displayName}
handle={item.actor.handle}
avatar={item.actor.avatar}
/>
</View>
<View style={styles.layoutContent}>
<Text style={[s.f15, s.bold, pal.text]}>
{item.actor.displayName || item.actor.handle}
</Text>
<Text style={[s.f14, s.gray5, pal.textLight]}>
@{item.actor.handle}
</Text>
</View>
</View>
</Link>
)
}
const styles = StyleSheet.create({
outer: {
marginTop: 1,
},
layout: {
flexDirection: 'row',
},
layoutAvi: {
width: 60,
paddingLeft: 10,
paddingTop: 10,
paddingBottom: 10,
},
avi: {
width: 40,
height: 40,
borderRadius: 20,
resizeMode: 'cover',
},
layoutContent: {
flex: 1,
paddingRight: 10,
paddingTop: 10,
paddingBottom: 10,
},
footer: {
height: 200,
paddingTop: 20,

View file

@ -10,7 +10,7 @@ import {observer} from 'mobx-react-lite'
import Clipboard from '@react-native-clipboard/clipboard'
import {AtUri} from '../../../third-party/uri'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {PostThreadViewModel} from '../../../state/models/post-thread-view'
import {PostThreadViewModel} from 'state/models/post-thread-view'
import {Link} from '../util/Link'
import {UserInfoText} from '../util/UserInfoText'
import {PostMeta} from '../util/PostMeta'
@ -20,9 +20,9 @@ import {Text} from '../util/text/Text'
import {RichText} from '../util/text/RichText'
import * as Toast from '../util/Toast'
import {UserAvatar} from '../util/UserAvatar'
import {useStores} from '../../../state'
import {s, colors} from '../../lib/styles'
import {usePalette} from '../../lib/hooks/usePalette'
import {useStores} from 'state/index'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
export const Post = observer(function Post({
uri,
@ -80,6 +80,8 @@ export const Post = observer(function Post({
const item = view.thread
const record = view.thread.postRecord
const itemUri = item.post.uri
const itemCid = item.post.cid
const itemUrip = new AtUri(item.post.uri)
const itemHref = `/profile/${item.post.author.handle}/post/${itemUrip.rkey}`
const itemTitle = `Post by ${item.post.author.handle}`
@ -105,12 +107,12 @@ export const Post = observer(function Post({
})
}
const onPressToggleRepost = () => {
item
return item
.toggleRepost()
.catch(e => store.log.error('Failed to toggle repost', e))
}
const onPressToggleUpvote = () => {
item
return item
.toggleUpvote()
.catch(e => store.log.error('Failed to toggle upvote', e))
}
@ -178,18 +180,19 @@ export const Post = observer(function Post({
<FontAwesomeIcon icon={['far', 'eye-slash']} style={s.mr2} />
<Text type="sm">This post is by a muted account.</Text>
</View>
) : record.text ? (
) : item.richText?.text ? (
<View style={styles.postTextContainer}>
<RichText
type="post-text"
text={record.text}
entities={record.entities}
richText={item.richText}
lineHeight={1.3}
/>
</View>
) : undefined}
<PostEmbeds embed={item.post.embed} style={s.mb10} />
<PostCtrls
itemUri={itemUri}
itemCid={itemCid}
itemHref={itemHref}
itemTitle={itemTitle}
isAuthor={item.post.author.did === store.me.did}

View file

@ -4,8 +4,8 @@ import {StyleProp, StyleSheet, TextStyle, View} from 'react-native'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Text} from '../util/text/Text'
import {PostModel} from '../../../state/models/post'
import {useStores} from '../../../state'
import {PostModel} from 'state/models/post'
import {useStores} from 'state/index'
export const PostText = observer(function PostText({
uri,

View file

@ -1,47 +1,5 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {Text} from '../util/text/Text'
import {usePalette} from '../../lib/hooks/usePalette'
export function ComposerPrompt({
onPressCompose,
}: {
export function ComposerPrompt(_opts: {
onPressCompose: (imagesOpen?: boolean) => void
}) {
const pal = usePalette('default')
return (
<View style={[pal.view, pal.border, styles.container]}>
<TouchableOpacity
testID="composePromptButton"
onPress={() => onPressCompose(false)}
style={[styles.btn, {backgroundColor: pal.colors.backgroundLight}]}>
<Text type="button" style={pal.text}>
New post
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => onPressCompose(true)}
style={[styles.btn, {backgroundColor: pal.colors.backgroundLight}]}>
<Text type="button" style={pal.text}>
Share photo
</Text>
</TouchableOpacity>
</View>
)
return null
}
const styles = StyleSheet.create({
container: {
paddingVertical: 12,
paddingHorizontal: 16,
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: 1,
},
btn: {
paddingVertical: 6,
paddingHorizontal: 14,
borderRadius: 30,
marginRight: 10,
},
})

View file

@ -1,8 +1,8 @@
import React from 'react'
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
import {Text} from '../util/text/Text'
import {usePalette} from '../../lib/hooks/usePalette'
import {s} from '../../lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
export function ComposerPrompt({
onPressCompose,

View file

@ -11,103 +11,144 @@ import {CenteredView, FlatList} from '../util/Views'
import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {EmptyState} from '../util/EmptyState'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {FeedModel} from '../../../state/models/feed-view'
import {FeedModel} from 'state/models/feed-view'
import {FeedItem} from './FeedItem'
import {ComposerPrompt} from './ComposerPrompt'
import {OnScrollCb} from '../../lib/hooks/useOnMainScroll'
import {s} from '../../lib/styles'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {s} from 'lib/styles'
import {useAnalytics} from 'lib/analytics'
const COMPOSE_PROMPT_ITEM = {_reactKey: '__prompt__'}
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
const ERROR_FEED_ITEM = {_reactKey: '__error__'}
export const Feed = observer(function Feed({
feed,
style,
scrollElRef,
onPressCompose,
onPressTryAgain,
onPressCompose,
onScroll,
testID,
headerOffset = 0,
}: {
feed: FeedModel
style?: StyleProp<ViewStyle>
scrollElRef?: MutableRefObject<FlatList<any> | null>
onPressCompose: (imagesOpen?: boolean) => void
onPressTryAgain?: () => void
onPressCompose: (imagesOpen?: boolean) => void
onScroll?: OnScrollCb
testID?: string
headerOffset?: number
}) {
const {track} = useAnalytics()
const [isRefreshing, setIsRefreshing] = React.useState(false)
const data = React.useMemo(() => {
let feedItems: any[] = []
if (feed.hasLoaded) {
feedItems = feedItems.concat([COMPOSE_PROMPT_ITEM])
if (feed.hasError) {
feedItems = feedItems.concat([ERROR_FEED_ITEM])
}
if (feed.isEmpty) {
feedItems = feedItems.concat([EMPTY_FEED_ITEM])
} else {
feedItems = feedItems.concat(feed.feed)
}
}
return feedItems
}, [feed.hasError, feed.hasLoaded, feed.isEmpty, feed.feed])
// events
// =
const onRefresh = React.useCallback(async () => {
track('Feed:onRefresh')
setIsRefreshing(true)
try {
await feed.refresh()
} catch (err) {
feed.rootStore.log.error('Failed to refresh posts feed', err)
}
setIsRefreshing(false)
}, [feed, track, setIsRefreshing])
const onEndReached = React.useCallback(async () => {
track('Feed:onEndReached')
try {
await feed.loadMore()
} catch (err) {
feed.rootStore.log.error('Failed to load more posts', err)
}
}, [feed, track])
// rendering
// =
// TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf
// VirtualizedList: You have a large list that is slow to update - make sure your
// renderItem function renders components that follow React performance best practices
// like PureComponent, shouldComponentUpdate, etc
const renderItem = ({item}: {item: any}) => {
if (item === COMPOSE_PROMPT_ITEM) {
return <ComposerPrompt onPressCompose={onPressCompose} />
} else if (item === EMPTY_FEED_ITEM) {
return (
<EmptyState
icon="bars"
message="This feed is empty!"
style={styles.emptyState}
/>
)
} else {
return <FeedItem item={item} />
}
}
const onRefresh = () => {
feed
.refresh()
.catch(err =>
feed.rootStore.log.error('Failed to refresh posts feed', err),
)
}
const onEndReached = () => {
feed
.loadMore()
.catch(err => feed.rootStore.log.error('Failed to load more posts', err))
}
let data
if (feed.hasLoaded) {
if (feed.isEmpty) {
data = [COMPOSE_PROMPT_ITEM, EMPTY_FEED_ITEM]
} else {
data = [COMPOSE_PROMPT_ITEM].concat(feed.feed)
}
}
const FeedFooter = () =>
feed.isLoading ? (
<View style={styles.feedFooter}>
<ActivityIndicator />
</View>
) : (
<View />
)
return (
<View testID={testID} style={style}>
<CenteredView>
{!data && <ComposerPrompt onPressCompose={onPressCompose} />}
{feed.isLoading && !data && <PostFeedLoadingPlaceholder />}
{feed.hasError && (
const renderItem = React.useCallback(
({item}: {item: any}) => {
if (item === COMPOSE_PROMPT_ITEM) {
return <ComposerPrompt onPressCompose={onPressCompose} />
} else if (item === EMPTY_FEED_ITEM) {
return (
<EmptyState
icon="bars"
message="This feed is empty!"
style={styles.emptyState}
/>
)
} else if (item === ERROR_FEED_ITEM) {
return (
<ErrorMessage
message={feed.error}
onPressTryAgain={onPressTryAgain}
/>
)}
</CenteredView>
{feed.hasLoaded && data && (
)
}
return <FeedItem item={item} />
},
[feed, onPressTryAgain, onPressCompose],
)
const FeedFooter = React.useCallback(
() =>
feed.isLoading ? (
<View style={styles.feedFooter}>
<ActivityIndicator />
</View>
) : (
<View />
),
[feed],
)
return (
<View testID={testID} style={style}>
{feed.isLoading && data.length === 0 && (
<CenteredView style={{paddingTop: headerOffset}}>
<PostFeedLoadingPlaceholder />
</CenteredView>
)}
{data.length > 0 && (
<FlatList
ref={scrollElRef}
data={data}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
ListFooterComponent={FeedFooter}
refreshing={feed.isRefreshing}
refreshing={isRefreshing}
contentContainerStyle={s.contentContainer}
onScroll={onScroll}
onRefresh={onRefresh}
onEndReached={onEndReached}
removeClippedSubviews={true}
contentInset={{top: headerOffset}}
contentOffset={{x: 0, y: headerOffset * -1}}
progressViewOffset={headerOffset}
/>
)}
</View>

View file

@ -8,7 +8,7 @@ import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {FeedItemModel} from '../../../state/models/feed-view'
import {FeedItemModel} from 'state/models/feed-view'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {UserInfoText} from '../util/UserInfoText'
@ -18,9 +18,10 @@ import {PostEmbeds} from '../util/PostEmbeds'
import {RichText} from '../util/text/RichText'
import * as Toast from '../util/Toast'
import {UserAvatar} from '../util/UserAvatar'
import {s} from '../../lib/styles'
import {useStores} from '../../../state'
import {usePalette} from '../../lib/hooks/usePalette'
import {s} from 'lib/styles'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics'
export const FeedItem = observer(function ({
item,
@ -33,8 +34,11 @@ export const FeedItem = observer(function ({
}) {
const store = useStores()
const pal = usePalette('default')
const {track} = useAnalytics()
const [deleted, setDeleted] = useState(false)
const record = item.postRecord
const itemUri = item.post.uri
const itemCid = item.post.cid
const itemHref = useMemo(() => {
const urip = new AtUri(item.post.uri)
return `/profile/${item.post.author.handle}/post/${urip.rkey}`
@ -50,6 +54,7 @@ export const FeedItem = observer(function ({
}, [record?.reply])
const onPressReply = () => {
track('FeedItem:PostReply')
store.shell.openComposer({
replyTo: {
uri: item.post.uri,
@ -64,12 +69,14 @@ export const FeedItem = observer(function ({
})
}
const onPressToggleRepost = () => {
item
track('FeedItem:PostRepost')
return item
.toggleRepost()
.catch(e => store.log.error('Failed to toggle repost', e))
}
const onPressToggleUpvote = () => {
item
track('FeedItem:PostLike')
return item
.toggleUpvote()
.catch(e => store.log.error('Failed to toggle upvote', e))
}
@ -78,6 +85,7 @@ export const FeedItem = observer(function ({
Toast.show('Copied to clipboard')
}
const onDeletePost = () => {
track('FeedItem:PostDelete')
item.delete().then(
() => {
setDeleted(true)
@ -195,12 +203,11 @@ export const FeedItem = observer(function ({
<FontAwesomeIcon icon={['far', 'eye-slash']} style={s.mr2} />
<Text type="sm">This post is by a muted account.</Text>
</View>
) : record.text ? (
) : item.richText?.text ? (
<View style={styles.postTextContainer}>
<RichText
type="post-text"
text={record.text}
entities={record.entities}
richText={item.richText}
lineHeight={1.3}
/>
</View>
@ -210,6 +217,8 @@ export const FeedItem = observer(function ({
) : null}
<PostCtrls
style={styles.ctrls}
itemUri={itemUri}
itemCid={itemCid}
itemHref={itemHref}
itemTitle={itemTitle}
isAuthor={item.post.author.did === store.me.did}

View file

@ -1,23 +1,29 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
import {s} from '../../lib/styles'
import {usePalette} from '../../lib/hooks/usePalette'
import * as Toast from '../util/Toast'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import * as apilib from 'lib/api/index'
export function ProfileCard({
handle,
displayName,
avatar,
description,
isFollowedBy,
renderButton,
onPressButton,
}: {
handle: string
displayName?: string
avatar?: string
description?: string
isFollowedBy?: boolean
renderButton?: () => JSX.Element
onPressButton?: () => void
}) {
const pal = usePalette('default')
return (
@ -36,30 +42,117 @@ export function ProfileCard({
/>
</View>
<View style={styles.layoutContent}>
<Text style={[s.bold, pal.text]} numberOfLines={1}>
<Text type="lg" style={[s.bold, pal.text]} numberOfLines={1}>
{displayName || handle}
</Text>
<Text type="sm" style={[pal.textLight]} numberOfLines={1}>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
@{handle}
</Text>
{isFollowedBy && (
<View style={s.flexRow}>
<View style={[s.mt5, pal.btn, styles.pill]}>
<Text type="xs">Follows You</Text>
</View>
</View>
)}
</View>
{renderButton ? (
<View style={styles.layoutButton}>
<TouchableOpacity
onPress={onPressButton}
style={[styles.btn, pal.btn]}>
{renderButton()}
</TouchableOpacity>
</View>
<View style={styles.layoutButton}>{renderButton()}</View>
) : undefined}
</View>
{description ? (
<View style={styles.details}>
<Text style={pal.text} numberOfLines={4}>
{description}
</Text>
</View>
) : undefined}
</Link>
)
}
export const ProfileCardWithFollowBtn = observer(
({
did,
declarationCid,
handle,
displayName,
avatar,
description,
isFollowedBy,
}: {
did: string
declarationCid: string
handle: string
displayName?: string
avatar?: string
description?: string
isFollowedBy?: boolean
}) => {
const store = useStores()
const isMe = store.me.handle === handle
const isFollowing = store.me.follows.isFollowing(did)
const onToggleFollow = async () => {
if (store.me.follows.isFollowing(did)) {
try {
await apilib.unfollow(store, store.me.follows.getFollowUri(did))
store.me.follows.removeFollow(did)
} catch (e: any) {
store.log.error('Failed fo delete follow', e)
Toast.show('An issue occurred, please try again.')
}
} else {
try {
const res = await apilib.follow(store, did, declarationCid)
store.me.follows.addFollow(did, res.uri)
} catch (e: any) {
store.log.error('Failed fo create follow', e)
Toast.show('An issue occurred, please try again.')
}
}
}
return (
<ProfileCard
handle={handle}
displayName={displayName}
avatar={avatar}
description={description}
isFollowedBy={isFollowedBy}
renderButton={
isMe
? undefined
: () => (
<FollowBtn isFollowing={isFollowing} onPress={onToggleFollow} />
)
}
/>
)
},
)
function FollowBtn({
isFollowing,
onPress,
}: {
isFollowing: boolean
onPress: () => void
}) {
const pal = usePalette('default')
return (
<TouchableOpacity onPress={onPress}>
<View style={[styles.btn, pal.btn]}>
<Text type="button" style={[pal.text]}>
{isFollowing ? 'Unfollow' : 'Follow'}
</Text>
</View>
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
outer: {
borderTopWidth: 1,
paddingHorizontal: 6,
},
layout: {
flexDirection: 'row',
@ -68,7 +161,7 @@ const styles = StyleSheet.create({
layoutAvi: {
width: 60,
paddingLeft: 10,
paddingTop: 10,
paddingTop: 8,
paddingBottom: 10,
},
avi: {
@ -80,19 +173,26 @@ const styles = StyleSheet.create({
layoutContent: {
flex: 1,
paddingRight: 10,
paddingTop: 12,
paddingTop: 10,
paddingBottom: 10,
},
layoutButton: {
paddingRight: 10,
},
details: {
paddingLeft: 60,
paddingRight: 10,
paddingBottom: 10,
},
pill: {
borderRadius: 4,
paddingHorizontal: 6,
paddingVertical: 2,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 7,
paddingHorizontal: 14,
borderRadius: 50,
marginLeft: 6,
paddingHorizontal: 14,
},
})

View file

@ -4,15 +4,11 @@ import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {
UserFollowersViewModel,
FollowerItem,
} from '../../../state/models/user-followers-view'
} from 'state/models/user-followers-view'
import {CenteredView, FlatList} from '../util/Views'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {UserAvatar} from '../util/UserAvatar'
import {useStores} from '../../../state'
import {s} from '../../lib/styles'
import {usePalette} from '../../lib/hooks/usePalette'
import {ProfileCardWithFollowBtn} from './ProfileCard'
import {useStores} from 'state/index'
export const ProfileFollowers = observer(function ProfileFollowers({
name,
@ -63,7 +59,15 @@ export const ProfileFollowers = observer(function ProfileFollowers({
// loaded
// =
const renderItem = ({item}: {item: FollowerItem}) => (
<User key={item.did} item={item} />
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
isFollowedBy={!!item.viewer?.followedBy}
/>
)
return (
<FlatList
@ -84,55 +88,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({
)
})
const User = ({item}: {item: FollowerItem}) => {
const pal = usePalette('default')
return (
<Link
style={[styles.outer, pal.view, pal.border]}
href={`/profile/${item.handle}`}
title={item.handle}
noFeedback>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar
size={40}
displayName={item.displayName}
handle={item.handle}
avatar={item.avatar}
/>
</View>
<View style={styles.layoutContent}>
<Text style={[s.bold, pal.text]}>
{item.displayName || item.handle}
</Text>
<Text type="sm" style={[pal.textLight]}>
@{item.handle}
</Text>
</View>
</View>
</Link>
)
}
const styles = StyleSheet.create({
outer: {
borderTopWidth: 1,
},
layout: {
flexDirection: 'row',
},
layoutAvi: {
width: 60,
paddingLeft: 10,
paddingTop: 10,
paddingBottom: 10,
},
layoutContent: {
flex: 1,
paddingRight: 10,
paddingTop: 10,
paddingBottom: 10,
},
footer: {
height: 200,
paddingTop: 20,

View file

@ -2,17 +2,10 @@ import React, {useEffect} from 'react'
import {observer} from 'mobx-react-lite'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {CenteredView, FlatList} from '../util/Views'
import {
UserFollowsViewModel,
FollowItem,
} from '../../../state/models/user-follows-view'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {UserFollowsViewModel, FollowItem} from 'state/models/user-follows-view'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {UserAvatar} from '../util/UserAvatar'
import {useStores} from '../../../state'
import {s} from '../../lib/styles'
import {usePalette} from '../../lib/hooks/usePalette'
import {ProfileCardWithFollowBtn} from './ProfileCard'
import {useStores} from 'state/index'
export const ProfileFollows = observer(function ProfileFollows({
name,
@ -63,7 +56,15 @@ export const ProfileFollows = observer(function ProfileFollows({
// loaded
// =
const renderItem = ({item}: {item: FollowItem}) => (
<User key={item.did} item={item} />
<ProfileCardWithFollowBtn
key={item.did}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
isFollowedBy={!!item.viewer?.followedBy}
/>
)
return (
<FlatList
@ -84,59 +85,7 @@ export const ProfileFollows = observer(function ProfileFollows({
)
})
const User = ({item}: {item: FollowItem}) => {
const pal = usePalette('default')
return (
<Link
style={[styles.outer, pal.view, pal.border]}
href={`/profile/${item.handle}`}
title={item.handle}
noFeedback>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar
size={40}
displayName={item.displayName}
handle={item.handle}
avatar={
item.avatar as
| string
| undefined /* HACK: type signature is wrong in the api */
}
/>
</View>
<View style={styles.layoutContent}>
<Text style={[s.bold, pal.text]}>
{item.displayName || item.handle}
</Text>
<Text type="sm" style={[pal.textLight]}>
@{item.handle}
</Text>
</View>
</View>
</Link>
)
}
const styles = StyleSheet.create({
outer: {
borderTopWidth: 1,
},
layout: {
flexDirection: 'row',
},
layoutAvi: {
width: 60,
paddingLeft: 10,
paddingTop: 10,
paddingBottom: 10,
},
layoutContent: {
flex: 1,
paddingRight: 10,
paddingTop: 10,
paddingBottom: 10,
},
footer: {
height: 200,
paddingTop: 20,

View file

@ -13,15 +13,16 @@ import {
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {BlurView} from '../util/BlurView'
import {ProfileViewModel} from '../../../state/models/profile-view'
import {useStores} from '../../../state'
import {ProfileViewModel} from 'state/models/profile-view'
import {useStores} from 'state/index'
import {
EditProfileModal,
ReportAccountModal,
ProfileImageLightbox,
} from '../../../state/models/shell-ui'
import {pluralize, toShareUrl} from '../../../lib/strings'
import {s, gradients} from '../../lib/styles'
} from 'state/models/shell-ui'
import {pluralize} from 'lib/strings/helpers'
import {toShareUrl} from 'lib/strings/url-helpers'
import {s, gradients} from 'lib/styles'
import {DropdownButton, DropdownItem} from '../util/forms/DropdownButton'
import * as Toast from '../util/Toast'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
@ -29,7 +30,8 @@ import {Text} from '../util/text/Text'
import {RichText} from '../util/text/RichText'
import {UserAvatar} from '../util/UserAvatar'
import {UserBanner} from '../util/UserBanner'
import {usePalette} from '../../lib/hooks/usePalette'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics'
export const ProfileHeader = observer(function ProfileHeader({
view,
@ -40,7 +42,7 @@ export const ProfileHeader = observer(function ProfileHeader({
}) {
const pal = usePalette('default')
const store = useStores()
const {track} = useAnalytics()
const onPressBack = () => {
store.nav.tab.goBack()
}
@ -53,7 +55,7 @@ export const ProfileHeader = observer(function ProfileHeader({
view?.toggleFollowing().then(
() => {
Toast.show(
`${view.myState.follow ? 'Following' : 'No longer following'} ${
`${view.viewer.following ? 'Following' : 'No longer following'} ${
view.displayName || view.handle
}`,
)
@ -62,18 +64,23 @@ export const ProfileHeader = observer(function ProfileHeader({
)
}
const onPressEditProfile = () => {
track('ProfileHeader:EditProfileButtonClicked')
store.shell.openModal(new EditProfileModal(view, onRefreshAll))
}
const onPressFollowers = () => {
track('ProfileHeader:FollowersButtonClicked')
store.nav.navigate(`/profile/${view.handle}/followers`)
}
const onPressFollows = () => {
track('ProfileHeader:FollowsButtonClicked')
store.nav.navigate(`/profile/${view.handle}/follows`)
}
const onPressShare = () => {
track('ProfileHeader:ShareButtonClicked')
Share.share({url: toShareUrl(`/profile/${view.handle}`)})
}
const onPressMuteAccount = async () => {
track('ProfileHeader:MuteAccountButtonClicked')
try {
await view.muteAccount()
Toast.show('Account muted')
@ -83,6 +90,7 @@ export const ProfileHeader = observer(function ProfileHeader({
}
}
const onPressUnmuteAccount = async () => {
track('ProfileHeader:UnmuteAccountButtonClicked')
try {
await view.unmuteAccount()
Toast.show('Account unmuted')
@ -92,6 +100,7 @@ export const ProfileHeader = observer(function ProfileHeader({
}
}
const onPressReportAccount = () => {
track('ProfileHeader:ReportAccountButtonClicked')
store.shell.openModal(new ReportAccountModal(view.did))
}
@ -110,7 +119,7 @@ export const ProfileHeader = observer(function ProfileHeader({
<LoadingPlaceholder width={100} height={31} style={styles.br50} />
</View>
<View style={styles.displayNameLine}>
<Text type="title-xl" style={[pal.text, styles.title]}>
<Text type="title-2xl" style={[pal.text, styles.title]}>
{view.displayName || view.handle}
</Text>
</View>
@ -135,8 +144,8 @@ export const ProfileHeader = observer(function ProfileHeader({
let dropdownItems: DropdownItem[] = [{label: 'Share', onPress: onPressShare}]
if (!isMe) {
dropdownItems.push({
label: view.myState.muted ? 'Unmute Account' : 'Mute Account',
onPress: view.myState.muted ? onPressUnmuteAccount : onPressMuteAccount,
label: view.viewer.muted ? 'Unmute Account' : 'Mute Account',
onPress: view.viewer.muted ? onPressUnmuteAccount : onPressMuteAccount,
})
dropdownItems.push({
label: 'Report Account',
@ -159,7 +168,7 @@ export const ProfileHeader = observer(function ProfileHeader({
</TouchableOpacity>
) : (
<>
{view.myState.follow ? (
{store.me.follows.isFollowing(view.did) ? (
<TouchableOpacity
onPress={onPressToggleFollow}
style={[styles.btn, styles.mainBtn, pal.btn]}>
@ -206,11 +215,18 @@ export const ProfileHeader = observer(function ProfileHeader({
) : undefined}
</View>
<View style={styles.displayNameLine}>
<Text type="title-xl" style={[pal.text, styles.title]}>
<Text type="title-2xl" style={[pal.text, styles.title]}>
{view.displayName || view.handle}
</Text>
</View>
<View style={styles.handleLine}>
{view.viewer.followedBy ? (
<View style={[styles.pill, pal.btn, s.mr5]}>
<Text type="xs" style={[pal.text]}>
Follows you
</Text>
</View>
) : undefined}
<Text style={pal.textLight}>@{view.handle}</Text>
</View>
<View style={styles.metricsLine}>
@ -247,22 +263,21 @@ export const ProfileHeader = observer(function ProfileHeader({
</Text>
</View>
</View>
{view.description ? (
{view.descriptionRichText ? (
<RichText
style={[styles.description, pal.text]}
numberOfLines={15}
text={view.description}
entities={view.descriptionEntities}
richText={view.descriptionRichText}
/>
) : undefined}
{view.myState.muted ? (
{view.viewer.muted ? (
<View style={[styles.detailLine, pal.btn, s.p5]}>
<FontAwesomeIcon
icon={['far', 'eye-slash']}
style={[pal.text, s.mr5]}
/>
<Text type="md" style={[s.mr2, pal.text]}>
Account muted.
Account muted
</Text>
</View>
) : undefined}
@ -369,6 +384,12 @@ const styles = StyleSheet.create({
marginBottom: 5,
},
pill: {
borderRadius: 4,
paddingHorizontal: 6,
paddingVertical: 2,
},
br40: {borderRadius: 40},
br50: {borderRadius: 50},
})

View file

@ -1,6 +1,6 @@
import React from 'react'
import {StyleSheet, View, ViewProps} from 'react-native'
import {addStyle} from '../../lib/addStyle'
import {addStyle} from 'lib/styles'
type BlurViewProps = ViewProps & {
blurType?: 'dark' | 'light'

View file

@ -6,8 +6,8 @@ import {
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {Text} from './text/Text'
import {UserGroupIcon} from '../../lib/icons'
import {usePalette} from '../../lib/hooks/usePalette'
import {UserGroupIcon} from 'lib/icons'
import {usePalette} from 'lib/hooks/usePalette'
export function EmptyState({
icon,

View file

@ -1,41 +1,53 @@
import React from 'react'
import {observer} from 'mobx-react-lite'
import {
Animated,
GestureResponderEvent,
StyleSheet,
TouchableWithoutFeedback,
View,
} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {colors, gradients} from '../../lib/styles'
import {useStores} from '../../../state'
import {colors, gradients} from 'lib/styles'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {useStores} from 'state/index'
type OnPress = ((event: GestureResponderEvent) => void) | undefined
export const FAB = observer(
({icon, onPress}: {icon: IconProp; onPress: OnPress}) => {
({
testID,
icon,
onPress,
}: {
testID?: string
icon: IconProp
onPress: OnPress
}) => {
const store = useStores()
const interp = useAnimatedValue(0)
React.useEffect(() => {
Animated.timing(interp, {
toValue: store.shell.minimalShellMode ? 1 : 0,
duration: 100,
useNativeDriver: true,
isInteraction: false,
}).start()
}, [interp, store.shell.minimalShellMode])
const transform = {
transform: [{translateY: Animated.multiply(interp, 60)}],
}
return (
<TouchableWithoutFeedback onPress={onPress}>
<View
style={[
styles.outer,
store.shell.minimalShellMode ? styles.lower : undefined,
]}>
<TouchableWithoutFeedback testID={testID} onPress={onPress}>
<Animated.View style={[styles.outer, transform]}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={styles.inner}>
<FontAwesomeIcon
size={24}
icon={icon}
color={colors.white}
style={styles.icon}
/>
<FontAwesomeIcon size={24} icon={icon} color={colors.white} />
</LinearGradient>
</View>
</Animated.View>
</TouchableWithoutFeedback>
)
},
@ -46,16 +58,10 @@ const styles = StyleSheet.create({
position: 'absolute',
zIndex: 1,
right: 22,
bottom: 84,
bottom: 94,
width: 60,
height: 60,
borderRadius: 30,
shadowColor: '#000',
shadowOpacity: 0.3,
shadowOffset: {width: 0, height: 1},
},
lower: {
bottom: 34,
},
inner: {
width: 60,
@ -64,5 +70,4 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
icon: {},
})

View file

@ -10,9 +10,9 @@ import {
ViewStyle,
} from 'react-native'
import {Text} from './text/Text'
import {TypographyVariant} from '../../lib/ThemeContext'
import {useStores, RootStoreModel} from '../../../state'
import {convertBskyAppUrlIfNeeded} from '../../../lib/strings'
import {TypographyVariant} from 'lib/ThemeContext'
import {useStores, RootStoreModel} from 'state/index'
import {convertBskyAppUrlIfNeeded} from 'lib/strings/url-helpers'
export const Link = observer(function Link({
style,
@ -22,17 +22,21 @@ export const Link = observer(function Link({
noFeedback,
}: {
style?: StyleProp<ViewStyle>
href: string
href?: string
title?: string
children?: React.ReactNode
noFeedback?: boolean
}) {
const store = useStores()
const onPress = () => {
handleLink(store, href, false)
if (href) {
handleLink(store, href, false)
}
}
const onLongPress = () => {
handleLink(store, href, true)
if (href) {
handleLink(store, href, true)
}
}
if (noFeedback) {
return (

View file

@ -4,9 +4,9 @@ import {observer} from 'mobx-react-lite'
import LinearGradient from 'react-native-linear-gradient'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {Text} from './text/Text'
import {colors, gradients} from '../../lib/styles'
import {colors, gradients} from 'lib/styles'
import {clamp} from 'lodash'
import {useStores} from '../../../state'
import {useStores} from 'state/index'
const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}

View file

@ -1,7 +1,7 @@
import React from 'react'
import {StyleSheet, TouchableOpacity} from 'react-native'
import {Text} from './text/Text'
import {usePalette} from '../../lib/hooks/usePalette'
import {usePalette} from 'lib/hooks/usePalette'
const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}

View file

@ -1,10 +1,10 @@
import React from 'react'
import {StyleSheet, StyleProp, View, ViewStyle} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {HeartIcon} from '../../lib/icons'
import {s} from '../../lib/styles'
import {useTheme} from '../../lib/ThemeContext'
import {usePalette} from '../../lib/hooks/usePalette'
import {HeartIcon} from 'lib/icons'
import {s} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
export function LoadingPlaceholder({
width,

View file

@ -16,7 +16,7 @@ import {
} from '@fortawesome/react-native-fontawesome'
import RootSiblings from 'react-native-root-siblings'
import {Text} from './text/Text'
import {colors} from '../../lib/styles'
import {colors} from 'lib/styles'
interface PickerItem {
value: string

View file

@ -1,6 +1,5 @@
import React from 'react'
import {
Animated,
StyleProp,
StyleSheet,
TouchableOpacity,
@ -12,6 +11,11 @@ import {
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import ReactNativeHapticFeedback from 'react-native-haptic-feedback'
// DISABLED see #135
// import {
// TriggerableAnimated,
// TriggerableAnimatedRef,
// } from './anim/TriggerableAnimated'
import {Text} from './text/Text'
import {PostDropdownBtn} from './forms/DropdownButton'
import {
@ -19,12 +23,13 @@ import {
HeartIconSolid,
RepostIcon,
CommentBottomArrow,
} from '../../lib/icons'
import {s, colors} from '../../lib/styles'
import {useTheme} from '../../lib/ThemeContext'
import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
} from 'lib/icons'
import {s, colors} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
interface PostCtrlsOpts {
itemUri: string
itemCid: string
itemHref: string
itemTitle: string
isAuthor: boolean
@ -36,13 +41,49 @@ interface PostCtrlsOpts {
isReposted: boolean
isUpvoted: boolean
onPressReply: () => void
onPressToggleRepost: () => void
onPressToggleUpvote: () => void
onPressToggleRepost: () => Promise<void>
onPressToggleUpvote: () => Promise<void>
onCopyPostText: () => void
onDeletePost: () => void
}
const HITSLOP = {top: 2, left: 2, bottom: 2, right: 2}
const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5}
// DISABLED see #135
/*
function ctrlAnimStart(interp: Animated.Value) {
return Animated.sequence([
Animated.timing(interp, {
toValue: 1,
duration: 250,
useNativeDriver: true,
}),
Animated.delay(50),
Animated.timing(interp, {
toValue: 0,
duration: 20,
useNativeDriver: true,
}),
])
}
function ctrlAnimStyle(interp: Animated.Value) {
return {
transform: [
{
scale: interp.interpolate({
inputRange: [0, 1.0],
outputRange: [1.0, 4.0],
}),
},
],
opacity: interp.interpolate({
inputRange: [0, 1.0],
outputRange: [1.0, 0.0],
}),
}
}
*/
export function PostCtrls(opts: PostCtrlsOpts) {
const theme = useTheme()
@ -51,76 +92,59 @@ export function PostCtrls(opts: PostCtrlsOpts) {
color: theme.palette.default.postCtrl,
}),
[theme],
)
const interp1 = useAnimatedValue(0)
const interp2 = useAnimatedValue(0)
const anim1Style = {
transform: [
{
scale: interp1.interpolate({
inputRange: [0, 1.0],
outputRange: [1.0, 4.0],
}),
},
],
opacity: interp1.interpolate({
inputRange: [0, 1.0],
outputRange: [1.0, 0.0],
}),
}
const anim2Style = {
transform: [
{
scale: interp2.interpolate({
inputRange: [0, 1.0],
outputRange: [1.0, 4.0],
}),
},
],
opacity: interp2.interpolate({
inputRange: [0, 1.0],
outputRange: [1.0, 0.0],
}),
}
) as StyleProp<ViewStyle>
const [repostMod, setRepostMod] = React.useState<number>(0)
const [likeMod, setLikeMod] = React.useState<number>(0)
// DISABLED see #135
// const repostRef = React.useRef<TriggerableAnimatedRef | null>(null)
// const likeRef = React.useRef<TriggerableAnimatedRef | null>(null)
const onPressToggleRepostWrapper = () => {
if (!opts.isReposted) {
ReactNativeHapticFeedback.trigger('impactMedium')
Animated.sequence([
Animated.timing(interp1, {
toValue: 1,
duration: 400,
useNativeDriver: true,
}),
Animated.delay(100),
Animated.timing(interp1, {
toValue: 0,
duration: 20,
useNativeDriver: true,
}),
]).start()
setRepostMod(1)
opts
.onPressToggleRepost()
.catch(_e => undefined)
.then(() => setRepostMod(0))
// DISABLED see #135
// repostRef.current?.trigger(
// {start: ctrlAnimStart, style: ctrlAnimStyle},
// async () => {
// await opts.onPressToggleRepost().catch(_e => undefined)
// setRepostMod(0)
// },
// )
} else {
setRepostMod(-1)
opts
.onPressToggleRepost()
.catch(_e => undefined)
.then(() => setRepostMod(0))
}
opts.onPressToggleRepost()
}
const onPressToggleUpvoteWrapper = () => {
if (!opts.isUpvoted) {
ReactNativeHapticFeedback.trigger('impactMedium')
Animated.sequence([
Animated.timing(interp2, {
toValue: 1,
duration: 400,
useNativeDriver: true,
}),
Animated.delay(100),
Animated.timing(interp2, {
toValue: 0,
duration: 20,
useNativeDriver: true,
}),
]).start()
setLikeMod(1)
opts
.onPressToggleUpvote()
.catch(_e => undefined)
.then(() => setLikeMod(0))
// DISABLED see #135
// likeRef.current?.trigger(
// {start: ctrlAnimStart, style: ctrlAnimStyle},
// async () => {
// await opts.onPressToggleUpvote().catch(_e => undefined)
// setLikeMod(0)
// },
// )
} else {
setLikeMod(-1)
opts
.onPressToggleUpvote()
.catch(_e => undefined)
.then(() => setLikeMod(0))
}
opts.onPressToggleUpvote()
}
return (
@ -147,7 +171,17 @@ export function PostCtrls(opts: PostCtrlsOpts) {
hitSlop={HITSLOP}
onPress={onPressToggleRepostWrapper}
style={styles.ctrl}>
<Animated.View style={anim1Style}>
<RepostIcon
style={
opts.isReposted || repostMod > 0
? (styles.ctrlIconReposted as StyleProp<ViewStyle>)
: defaultCtrlColor
}
strokeWidth={2.4}
size={opts.big ? 24 : 20}
/>
{
undefined /*DISABLED see #135 <TriggerableAnimated ref={repostRef}>
<RepostIcon
style={
(opts.isReposted
@ -157,15 +191,16 @@ export function PostCtrls(opts: PostCtrlsOpts) {
strokeWidth={2.4}
size={opts.big ? 24 : 20}
/>
</Animated.View>
</TriggerableAnimated>*/
}
{typeof opts.repostCount !== 'undefined' ? (
<Text
style={
opts.isReposted
opts.isReposted || repostMod > 0
? [s.bold, s.green3, s.f15, s.ml5]
: [defaultCtrlColor, s.f15, s.ml5]
}>
{opts.repostCount}
{opts.repostCount + repostMod}
</Text>
) : undefined}
</TouchableOpacity>
@ -175,8 +210,21 @@ export function PostCtrls(opts: PostCtrlsOpts) {
style={styles.ctrl}
hitSlop={HITSLOP}
onPress={onPressToggleUpvoteWrapper}>
<Animated.View style={anim2Style}>
{opts.isUpvoted ? (
{opts.isUpvoted || likeMod > 0 ? (
<HeartIconSolid
style={styles.ctrlIconUpvoted as StyleProp<ViewStyle>}
size={opts.big ? 22 : 16}
/>
) : (
<HeartIcon
style={[defaultCtrlColor, opts.big ? styles.mt1 : undefined]}
strokeWidth={3}
size={opts.big ? 20 : 16}
/>
)}
{
undefined /*DISABLED see #135 <TriggerableAnimated ref={likeRef}>
{opts.isUpvoted || likeMod > 0 ? (
<HeartIconSolid
style={styles.ctrlIconUpvoted as ViewStyle}
size={opts.big ? 22 : 16}
@ -191,15 +239,16 @@ export function PostCtrls(opts: PostCtrlsOpts) {
size={opts.big ? 20 : 16}
/>
)}
</Animated.View>
</TriggerableAnimated>*/
}
{typeof opts.upvoteCount !== 'undefined' ? (
<Text
style={
opts.isUpvoted
opts.isUpvoted || likeMod > 0
? [s.bold, s.red3, s.f15, s.ml5]
: [defaultCtrlColor, s.f15, s.ml5]
}>
{opts.upvoteCount}
{opts.upvoteCount + likeMod}
</Text>
) : undefined}
</TouchableOpacity>
@ -208,6 +257,8 @@ export function PostCtrls(opts: PostCtrlsOpts) {
{opts.big ? undefined : (
<PostDropdownBtn
style={styles.ctrl}
itemUri={opts.itemUri}
itemCid={opts.itemCid}
itemHref={opts.itemHref}
itemTitle={opts.itemTitle}
isAuthor={opts.isAuthor}

View file

@ -0,0 +1,69 @@
import React from 'react'
import {Text} from '../text/Text'
import {AutoSizedImage} from '../images/AutoSizedImage'
import {StyleSheet, View} from 'react-native'
import {usePalette} from 'lib/hooks/usePalette'
import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external'
const ExternalLinkEmbed = ({
link,
onImagePress,
imageChild,
}: {
link: PresentedExternal
onImagePress?: () => void
imageChild?: React.ReactNode
}) => {
const pal = usePalette('default')
return (
<>
{link.thumb ? (
<AutoSizedImage
uri={link.thumb}
style={styles.extImage}
onPress={onImagePress}>
{imageChild}
</AutoSizedImage>
) : undefined}
<View style={styles.extInner}>
<Text type="md-bold" numberOfLines={2} style={[pal.text]}>
{link.title || link.uri}
</Text>
<Text
type="sm"
numberOfLines={1}
style={[pal.textLight, styles.extUri]}>
{link.uri}
</Text>
{link.description ? (
<Text
type="sm"
numberOfLines={2}
style={[pal.text, styles.extDescription]}>
{link.description}
</Text>
) : undefined}
</View>
</>
)
}
const styles = StyleSheet.create({
extInner: {
padding: 10,
},
extImage: {
borderTopLeftRadius: 6,
borderTopRightRadius: 6,
width: '100%',
maxHeight: 200,
},
extUri: {
marginTop: 2,
},
extDescription: {
marginTop: 4,
},
})
export default ExternalLinkEmbed

View file

@ -0,0 +1,119 @@
import React, {useEffect} from 'react'
import {useState} from 'react'
import {
View,
StyleSheet,
Pressable,
TouchableWithoutFeedback,
EmitterSubscription,
} from 'react-native'
import YoutubePlayer from 'react-native-youtube-iframe'
import {usePalette} from 'lib/hooks/usePalette'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import ExternalLinkEmbed from './ExternalLinkEmbed'
import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external'
import {useStores} from 'state/index'
const YoutubeEmbed = ({
link,
videoId,
}: {
videoId: string
link: PresentedExternal
}) => {
const store = useStores()
const [displayVideoPlayer, setDisplayVideoPlayer] = useState(false)
const [playerDimensions, setPlayerDimensions] = useState({
width: 0,
height: 0,
})
const pal = usePalette('default')
const handlePlayButtonPressed = () => {
setDisplayVideoPlayer(true)
}
const handleOnLayout = (event: {
nativeEvent: {layout: {width: any; height: any}}
}) => {
setPlayerDimensions({
width: event.nativeEvent.layout.width,
height: event.nativeEvent.layout.height,
})
}
useEffect(() => {
let sub: EmitterSubscription
if (displayVideoPlayer) {
sub = store.onNavigation(() => {
setDisplayVideoPlayer(false)
})
}
return () => sub && sub.remove()
}, [displayVideoPlayer, store])
const imageChild = (
<Pressable onPress={handlePlayButtonPressed} style={styles.playButton}>
<FontAwesomeIcon icon="play" size={24} color="white" />
</Pressable>
)
if (!displayVideoPlayer) {
return (
<View
style={[styles.extOuter, pal.view, pal.border]}
onLayout={handleOnLayout}>
<ExternalLinkEmbed
link={link}
onImagePress={handlePlayButtonPressed}
imageChild={imageChild}
/>
</View>
)
}
const height = (playerDimensions.width / 16) * 9
const noop = () => {}
return (
<TouchableWithoutFeedback onPress={noop}>
<View>
{/* Removing the outter View will make tap events propagate to parents */}
<YoutubePlayer
initialPlayerParams={{
modestbranding: true,
}}
webViewProps={{
startInLoadingState: true,
}}
height={height}
videoId={videoId}
webViewStyle={styles.webView}
/>
</View>
</TouchableWithoutFeedback>
)
}
const styles = StyleSheet.create({
extOuter: {
borderWidth: 1,
borderRadius: 8,
marginTop: 4,
},
playButton: {
position: 'absolute',
alignSelf: 'center',
alignItems: 'center',
top: '44%',
justifyContent: 'center',
backgroundColor: 'black',
padding: 10,
borderRadius: 50,
opacity: 0.8,
},
webView: {
alignItems: 'center',
alignContent: 'center',
justifyContent: 'center',
},
})
export default YoutubeEmbed

View file

@ -1,16 +1,22 @@
import React from 'react'
import {StyleSheet, StyleProp, View, ViewStyle} from 'react-native'
import {
StyleSheet,
StyleProp,
View,
ViewStyle,
Image as RNImage,
} from 'react-native'
import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api'
import LinearGradient from 'react-native-linear-gradient'
import {Link} from '../util/Link'
import {Text} from './text/Text'
import {AutoSizedImage} from './images/AutoSizedImage'
import {ImageLayoutGrid} from './images/ImageLayoutGrid'
import {ImagesLightbox} from '../../../state/models/shell-ui'
import {useStores} from '../../../state'
import {usePalette} from '../../lib/hooks/usePalette'
import {gradients} from '../../lib/styles'
import {saveImageModal} from '../../../lib/images'
import {Link} from '../Link'
import {AutoSizedImage} from '../images/AutoSizedImage'
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
import {ImagesLightbox} from 'state/models/shell-ui'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {saveImageModal} from 'lib/images'
import YoutubeEmbed from './YoutubeEmbed'
import ExternalLinkEmbed from './ExternalLinkEmbed'
import {getYoutubeVideoId} from 'lib/strings/url-helpers'
type Embed =
| AppBskyEmbedImages.Presented
@ -35,6 +41,16 @@ export function PostEmbeds({
const onLongPress = (index: number) => {
saveImageModal({uri: uris[index]})
}
const onPressIn = (index: number) => {
const firstImageToShow = uris[index]
RNImage.prefetch(firstImageToShow)
uris.forEach(uri => {
if (firstImageToShow !== uri) {
// First image already prefeched above
RNImage.prefetch(uri)
}
})
}
if (embed.images.length === 4) {
return (
@ -44,6 +60,7 @@ export function PostEmbeds({
uris={embed.images.map(img => img.thumb)}
onPress={openLightbox}
onLongPress={onLongPress}
onPressIn={onPressIn}
/>
</View>
)
@ -55,6 +72,7 @@ export function PostEmbeds({
uris={embed.images.map(img => img.thumb)}
onPress={openLightbox}
onLongPress={onLongPress}
onPressIn={onPressIn}
/>
</View>
)
@ -66,6 +84,7 @@ export function PostEmbeds({
uris={embed.images.map(img => img.thumb)}
onPress={openLightbox}
onLongPress={onLongPress}
onPressIn={onPressIn}
/>
</View>
)
@ -76,7 +95,8 @@ export function PostEmbeds({
uri={embed.images[0].thumb}
onPress={() => openLightbox(0)}
onLongPress={() => onLongPress(0)}
containerStyle={styles.singleImage}
onPressIn={() => onPressIn(0)}
style={styles.singleImage}
/>
</View>
)
@ -85,40 +105,18 @@ export function PostEmbeds({
}
if (AppBskyEmbedExternal.isPresented(embed)) {
const link = embed.external
const youtubeVideoId = getYoutubeVideoId(link.uri)
if (youtubeVideoId) {
return <YoutubeEmbed videoId={youtubeVideoId} link={link} />
}
return (
<Link
style={[styles.extOuter, pal.view, pal.border, style]}
href={link.uri}
noFeedback>
{link.thumb ? (
<AutoSizedImage uri={link.thumb} containerStyle={styles.extImage} />
) : (
<LinearGradient
colors={[gradients.blueDark.start, gradients.blueDark.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.extImage, styles.extImageFallback]}
/>
)}
<View style={styles.extInner}>
<Text type="md-bold" numberOfLines={2} style={[pal.text]}>
{link.title || link.uri}
</Text>
<Text
type="sm"
numberOfLines={1}
style={[pal.textLight, styles.extUri]}>
{link.uri}
</Text>
{link.description ? (
<Text
type="sm"
numberOfLines={2}
style={[pal.text, styles.extDescription]}>
{link.description}
</Text>
) : undefined}
</View>
<ExternalLinkEmbed link={link} />
</Link>
)
}
@ -131,28 +129,11 @@ const styles = StyleSheet.create({
},
singleImage: {
borderRadius: 8,
maxHeight: 500,
},
extOuter: {
borderWidth: 1,
borderRadius: 8,
marginTop: 4,
},
extInner: {
padding: 10,
},
extImage: {
borderTopLeftRadius: 6,
borderTopRightRadius: 6,
width: '100%',
maxHeight: 200,
},
extImageFallback: {
height: 160,
},
extUri: {
marginTop: 2,
},
extDescription: {
marginTop: 4,
},
})

View file

@ -1,8 +1,8 @@
import React from 'react'
import {Platform, StyleSheet, View} from 'react-native'
import {Text} from './text/Text'
import {ago} from '../../../lib/strings'
import {usePalette} from '../../lib/hooks/usePalette'
import {ago} from 'lib/strings/time'
import {usePalette} from 'lib/hooks/usePalette'
interface PostMetaOpts {
authorHandle: string

View file

@ -6,7 +6,7 @@ import {
View,
} from 'react-native'
import {Text} from './text/Text'
import {usePalette} from '../../lib/hooks/usePalette'
import {usePalette} from 'lib/hooks/usePalette'
interface Layout {
x: number

View file

@ -1,15 +1,23 @@
import React, {useCallback} from 'react'
import {Alert, Image, StyleSheet, TouchableOpacity, View} from 'react-native'
import React from 'react'
import {StyleSheet, View} from 'react-native'
import Svg, {Circle, Text, Defs, LinearGradient, Stop} from 'react-native-svg'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {HighPriorityImage} from 'view/com/util/images/Image'
import {
openCamera,
openCropper,
openPicker,
PickedMedia,
} from './images/image-crop-picker/ImageCropPicker'
import {useStores} from '../../../state'
import {colors, gradients} from '../../lib/styles'
import {
requestPhotoAccessIfNeeded,
requestCameraAccessIfNeeded,
} from 'lib/permissions'
import {useStores} from 'state/index'
import {colors, gradients} from 'lib/styles'
import {DropdownButton} from './forms/DropdownButton'
import {usePalette} from 'lib/hooks/usePalette'
export function UserAvatar({
size,
@ -25,40 +33,9 @@ export function UserAvatar({
onSelectNewAvatar?: (img: PickedMedia) => void
}) {
const store = useStores()
const pal = usePalette('default')
const initials = getInitials(displayName || handle)
const handleEditAvatar = useCallback(() => {
Alert.alert('Select upload method', '', [
{
text: 'Take a new photo',
onPress: () => {
openCamera(store, {
mediaType: 'photo',
width: 1000,
height: 1000,
cropperCircleOverlay: true,
}).then(onSelectNewAvatar)
},
},
{
text: 'Select from gallery',
onPress: () => {
openPicker(store, {
mediaType: 'photo',
}).then(async items => {
await openCropper(store, {
mediaType: 'photo',
path: items[0].path,
width: 1000,
height: 1000,
cropperCircleOverlay: true,
}).then(onSelectNewAvatar)
})
},
},
])
}, [store, onSelectNewAvatar])
const renderSvg = (svgSize: number, svgInitials: string) => (
<Svg width={svgSize} height={svgSize} viewBox="0 0 100 100">
<Defs>
@ -80,11 +57,65 @@ export function UserAvatar({
</Svg>
)
const dropdownItems = [
{
label: 'Camera',
icon: 'camera' as IconProp,
onPress: async () => {
if (!(await requestCameraAccessIfNeeded())) {
return
}
onSelectNewAvatar?.(
await openCamera(store, {
mediaType: 'photo',
width: 1000,
height: 1000,
cropperCircleOverlay: true,
}),
)
},
},
{
label: 'Library',
icon: 'image' as IconProp,
onPress: async () => {
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker(store, {
mediaType: 'photo',
})
onSelectNewAvatar?.(
await openCropper(store, {
mediaType: 'photo',
path: items[0].path,
width: 1000,
height: 1000,
cropperCircleOverlay: true,
}),
)
},
},
// TODO: Remove avatar https://github.com/bluesky-social/social-app/issues/122
// {
// label: 'Remove',
// icon: ['far', 'trash-can'],
// onPress: () => {
// // Remove avatar API call
// },
// },
]
// onSelectNewAvatar is only passed as prop on the EditProfile component
return onSelectNewAvatar ? (
<TouchableOpacity onPress={handleEditAvatar}>
<DropdownButton
type="bare"
items={dropdownItems}
openToRight
rightOffset={-10}
bottomOffset={-10}
menuWidth={170}>
{avatar ? (
<Image
<HighPriorityImage
style={{
width: size,
height: size,
@ -95,16 +126,16 @@ export function UserAvatar({
) : (
renderSvg(size, initials)
)}
<View style={styles.editButtonContainer}>
<View style={[styles.editButtonContainer, pal.btn]}>
<FontAwesomeIcon
icon="camera"
size={12}
style={{color: colors.white}}
color={pal.text.color as string}
/>
</View>
</TouchableOpacity>
</DropdownButton>
) : avatar ? (
<Image
<HighPriorityImage
style={{width: size, height: size, borderRadius: Math.floor(size / 2)}}
resizeMode="stretch"
source={{uri: avatar}}

View file

@ -1,15 +1,23 @@
import React, {useCallback} from 'react'
import {StyleSheet, View, TouchableOpacity, Alert, Image} from 'react-native'
import React from 'react'
import {StyleSheet, View} from 'react-native'
import Svg, {Rect, Defs, LinearGradient, Stop} from 'react-native-svg'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {colors, gradients} from '../../lib/styles'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import Image from 'view/com/util/images/Image'
import {colors, gradients} from 'lib/styles'
import {
openCamera,
openCropper,
openPicker,
PickedMedia,
} from './images/image-crop-picker/ImageCropPicker'
import {useStores} from '../../../state'
import {useStores} from 'state/index'
import {
requestPhotoAccessIfNeeded,
requestCameraAccessIfNeeded,
} from 'lib/permissions'
import {DropdownButton} from './forms/DropdownButton'
import {usePalette} from 'lib/hooks/usePalette'
export function UserBanner({
banner,
@ -19,39 +27,57 @@ export function UserBanner({
onSelectNewBanner?: (img: PickedMedia) => void
}) {
const store = useStores()
const handleEditBanner = useCallback(() => {
Alert.alert('Select upload method', '', [
{
text: 'Take a new photo',
onPress: () => {
openCamera(store, {
const pal = usePalette('default')
const dropdownItems = [
{
label: 'Camera',
icon: 'camera' as IconProp,
onPress: async () => {
if (!(await requestCameraAccessIfNeeded())) {
return
}
onSelectNewBanner?.(
await openCamera(store, {
mediaType: 'photo',
// compressImageMaxWidth: 3000, TODO needed?
width: 3000,
// compressImageMaxHeight: 1000, TODO needed?
height: 1000,
}).then(onSelectNewBanner)
},
}),
)
},
{
text: 'Select from gallery',
onPress: () => {
openPicker(store, {
},
{
label: 'Library',
icon: 'image' as IconProp,
onPress: async () => {
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker(store, {
mediaType: 'photo',
})
onSelectNewBanner?.(
await openCropper(store, {
mediaType: 'photo',
}).then(async items => {
await openCropper(store, {
mediaType: 'photo',
path: items[0].path,
// compressImageMaxWidth: 3000, TODO needed?
width: 3000,
// compressImageMaxHeight: 1000, TODO needed?
height: 1000,
}).then(onSelectNewBanner)
})
},
path: items[0].path,
// compressImageMaxWidth: 3000, TODO needed?
width: 3000,
// compressImageMaxHeight: 1000, TODO needed?
height: 1000,
}),
)
},
])
}, [store, onSelectNewBanner])
},
// TODO: Remove banner https://github.com/bluesky-social/social-app/issues/122
// {
// label: 'Remove',
// icon: ['far', 'trash-can'],
// onPress: () => {
// // Remove banner api call
// },
// },
]
const renderSvg = () => (
<Svg width="100%" height="150" viewBox="50 0 200 100">
@ -72,20 +98,27 @@ export function UserBanner({
// setUserBanner is only passed as prop on the EditProfile component
return onSelectNewBanner ? (
<TouchableOpacity onPress={handleEditBanner}>
<DropdownButton
type="bare"
items={dropdownItems}
openToRight
rightOffset={-200}
bottomOffset={-10}
menuWidth={170}>
{banner ? (
<Image style={styles.bannerImage} source={{uri: banner}} />
) : (
renderSvg()
)}
<View style={styles.editButtonContainer}>
<View style={[styles.editButtonContainer, pal.btn]}>
<FontAwesomeIcon
icon="camera"
size={12}
style={{color: colors.white}}
color={pal.text.color as string}
/>
</View>
</TouchableOpacity>
</DropdownButton>
) : banner ? (
<Image
style={styles.bannerImage}

View file

@ -4,8 +4,8 @@ import {StyleProp, StyleSheet, TextStyle} from 'react-native'
import {Link} from './Link'
import {Text} from './text/Text'
import {LoadingPlaceholder} from './LoadingPlaceholder'
import {useStores} from '../../../state'
import {TypographyVariant} from '../../lib/ThemeContext'
import {useStores} from 'state/index'
import {TypographyVariant} from 'lib/ThemeContext'
export function UserInfoText({
type = 'md',

View file

@ -1,56 +1,40 @@
import React from 'react'
import {observer} from 'mobx-react-lite'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {CenteredView} from './Views'
import {Animated, StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {UserAvatar} from './UserAvatar'
import {Text} from './text/Text'
import {MagnifyingGlassIcon} from '../../lib/icons'
import {useStores} from '../../../state'
import {usePalette} from '../../lib/hooks/usePalette'
import {colors} from '../../lib/styles'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {useAnalytics} from 'lib/analytics'
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
export const ViewHeader = observer(function ViewHeader({
title,
subtitle,
canGoBack,
hideOnScroll,
}: {
title: string
subtitle?: string
canGoBack?: boolean
hideOnScroll?: boolean
}) {
const pal = usePalette('default')
const store = useStores()
const {track} = useAnalytics()
const onPressBack = () => {
store.nav.tab.goBack()
}
const onPressMenu = () => {
track('ViewHeader:MenuButtonClicked')
store.shell.setMainMenuOpen(true)
}
const onPressSearch = () => {
store.nav.navigate('/search')
}
const onPressReconnect = () => {
store.session.connect().catch(e => {
store.log.warn('Failed to reconnect to server', e)
})
}
if (typeof canGoBack === 'undefined') {
canGoBack = store.nav.tab.canGoBack
}
return (
<CenteredView style={[styles.header, pal.view]}>
<Container hideOnScroll={hideOnScroll || false}>
<TouchableOpacity
testID="viewHeaderBackOrMenuBtn"
onPress={canGoBack ? onPressBack : onPressMenu}
@ -75,48 +59,57 @@ export const ViewHeader = observer(function ViewHeader({
<Text type="title" style={[pal.text, styles.title]}>
{title}
</Text>
{subtitle ? (
<Text
type="title-sm"
style={[styles.subtitle, pal.textLight]}
numberOfLines={1}>
{subtitle}
</Text>
) : undefined}
</View>
<TouchableOpacity
onPress={onPressSearch}
hitSlop={HITSLOP}
style={styles.btn}>
<MagnifyingGlassIcon size={21} strokeWidth={3} style={pal.text} />
</TouchableOpacity>
{!store.session.online ? (
<TouchableOpacity style={styles.btn} onPress={onPressReconnect}>
{store.session.attemptingConnect ? (
<ActivityIndicator />
) : (
<>
<FontAwesomeIcon
icon="signal"
style={pal.text as FontAwesomeIconStyle}
size={16}
/>
<FontAwesomeIcon
icon="x"
style={[
styles.littleXIcon,
{backgroundColor: pal.colors.background},
]}
size={8}
/>
</>
)}
</TouchableOpacity>
) : undefined}
</CenteredView>
<View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
</Container>
)
})
const Container = observer(
({
children,
hideOnScroll,
}: {
children: React.ReactNode
hideOnScroll: boolean
}) => {
const store = useStores()
const pal = usePalette('default')
const interp = useAnimatedValue(0)
React.useEffect(() => {
if (store.shell.minimalShellMode) {
Animated.timing(interp, {
toValue: 1,
duration: 100,
useNativeDriver: true,
isInteraction: false,
}).start()
} else {
Animated.timing(interp, {
toValue: 0,
duration: 100,
useNativeDriver: true,
isInteraction: false,
}).start()
}
}, [interp, store.shell.minimalShellMode])
const transform = {
transform: [{translateY: Animated.multiply(interp, -100)}],
}
if (!hideOnScroll) {
return <View style={[styles.header, pal.view]}>{children}</View>
}
return (
<Animated.View
style={[styles.header, pal.view, styles.headerFloating, transform]}>
{children}
</Animated.View>
)
},
)
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
@ -125,20 +118,20 @@ const styles = StyleSheet.create({
paddingTop: 6,
paddingBottom: 6,
},
headerFloating: {
position: 'absolute',
top: 0,
width: '100%',
},
titleContainer: {
flexDirection: 'row',
alignItems: 'baseline',
marginLeft: 'auto',
marginRight: 'auto',
paddingRight: 10,
},
title: {
fontWeight: 'bold',
},
subtitle: {
marginLeft: 4,
maxWidth: 200,
fontWeight: 'normal',
},
backBtn: {
width: 30,
@ -152,19 +145,4 @@ const styles = StyleSheet.create({
backIcon: {
marginTop: 6,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: 36,
height: 36,
borderRadius: 20,
marginLeft: 4,
},
littleXIcon: {
color: colors.red3,
position: 'absolute',
right: 7,
bottom: 7,
},
})

View file

@ -1,20 +1,12 @@
import React from 'react'
import {observer} from 'mobx-react-lite'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {CenteredView} from './Views'
import {Text} from './text/Text'
import {useStores} from '../../../state'
import {usePalette} from '../../lib/hooks/usePalette'
import {colors} from '../../lib/styles'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {colors} from 'lib/styles'
const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
@ -32,11 +24,6 @@ export const ViewHeader = observer(function ViewHeader({
const onPressBack = () => {
store.nav.tab.goBack()
}
const onPressReconnect = () => {
store.session.connect().catch(e => {
store.log.warn('Failed to reconnect to server', e)
})
}
if (typeof canGoBack === 'undefined') {
canGoBack = store.nav.tab.canGoBack
}
@ -76,29 +63,6 @@ export const ViewHeader = observer(function ViewHeader({
</Text>
</View>
)}
{!store.session.online ? (
<TouchableOpacity style={styles.btn} onPress={onPressReconnect}>
{store.session.attemptingConnect ? (
<ActivityIndicator />
) : (
<>
<FontAwesomeIcon
icon="signal"
style={pal.text as FontAwesomeIconStyle}
size={16}
/>
<FontAwesomeIcon
icon="x"
style={[
styles.littleXIcon,
{backgroundColor: pal.colors.background},
]}
size={8}
/>
</>
)}
</TouchableOpacity>
) : undefined}
</CenteredView>
)
})

View file

@ -3,10 +3,10 @@ import {View} from 'react-native'
import {Selector} from './Selector'
import {HorzSwipe} from './gestures/HorzSwipe'
import {FlatList} from './Views'
import {useAnimatedValue} from '../../lib/hooks/useAnimatedValue'
import {OnScrollCb} from '../../lib/hooks/useOnMainScroll'
import {clamp} from '../../../lib/numbers'
import {s} from '../../lib/styles'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
import {clamp} from 'lib/numbers'
import {s} from 'lib/styles'
const HEADER_ITEM = {_reactKey: '__header__'}
const SELECTOR_ITEM = {_reactKey: '__selector__'}
@ -101,6 +101,7 @@ export function ViewSelector({
onRefresh={onRefresh}
onEndReached={onEndReached}
contentContainerStyle={s.contentContainer}
removeClippedSubviews={true}
/>
</HorzSwipe>
)

View file

@ -22,9 +22,8 @@ import {
View,
ViewProps,
} from 'react-native'
import {useTheme} from '../../lib/ThemeContext'
import {addStyle} from '../../lib/addStyle'
import {colors} from '../../lib/styles'
import {useTheme} from 'lib/ThemeContext'
import {addStyle, colors} from 'lib/styles'
export function CenteredView({
style,

View file

@ -0,0 +1,73 @@
import React from 'react'
import {Animated, StyleProp, View, ViewStyle} from 'react-native'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
type CreateAnimFn = (interp: Animated.Value) => Animated.CompositeAnimation
type FinishCb = () => void
interface TriggeredAnimation {
start: CreateAnimFn
style: (
interp: Animated.Value,
) => Animated.WithAnimatedValue<StyleProp<ViewStyle>>
}
export interface TriggerableAnimatedRef {
trigger: (anim: TriggeredAnimation, onFinish?: FinishCb) => void
}
type TriggerableAnimatedProps = React.PropsWithChildren<{}>
type PropsInner = TriggerableAnimatedProps & {
anim: TriggeredAnimation
onFinish: () => void
}
export const TriggerableAnimated = React.forwardRef<
TriggerableAnimatedRef,
TriggerableAnimatedProps
>(({children, ...props}, ref) => {
const [anim, setAnim] = React.useState<TriggeredAnimation | undefined>(
undefined,
)
const [finishCb, setFinishCb] = React.useState<FinishCb | undefined>(
undefined,
)
React.useImperativeHandle(ref, () => ({
trigger(v: TriggeredAnimation, cb?: FinishCb) {
setFinishCb(() => cb) // note- wrap in function due to react behaviors around setstate
setAnim(v)
},
}))
const onFinish = () => {
finishCb?.()
setAnim(undefined)
setFinishCb(undefined)
}
return (
<View key="triggerable">
{anim ? (
<AnimatingView anim={anim} onFinish={onFinish} {...props}>
{children}
</AnimatingView>
) : (
children
)}
</View>
)
})
function AnimatingView({
anim,
onFinish,
children,
}: React.PropsWithChildren<PropsInner>) {
const interp = useAnimatedValue(0)
React.useEffect(() => {
anim?.start(interp).start(() => {
onFinish()
})
})
const animStyle = anim?.style(interp)
return <Animated.View style={animStyle}>{children}</Animated.View>
}

View file

@ -11,8 +11,8 @@ import {
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {Text} from '../text/Text'
import {useTheme} from '../../../lib/ThemeContext'
import {usePalette} from '../../../lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
export function ErrorMessage({
message,

View file

@ -5,9 +5,9 @@ import {
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {Text} from '../text/Text'
import {colors} from '../../../lib/styles'
import {useTheme} from '../../../lib/ThemeContext'
import {usePalette} from '../../../lib/hooks/usePalette'
import {colors} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
export function ErrorScreen({
title,

View file

@ -7,8 +7,8 @@ import {
ViewStyle,
} from 'react-native'
import {Text} from '../text/Text'
import {useTheme} from '../../../lib/ThemeContext'
import {choose} from '../../../../lib/functions'
import {useTheme} from 'lib/ThemeContext'
import {choose} from 'lib/functions'
export type ButtonType =
| 'primary'

View file

@ -13,11 +13,13 @@ import RootSiblings from 'react-native-root-siblings'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../text/Text'
import {Button, ButtonType} from './Button'
import {colors} from '../../../lib/styles'
import {toShareUrl} from '../../../../lib/strings'
import {useStores} from '../../../../state'
import {ReportPostModal, ConfirmModal} from '../../../../state/models/shell-ui'
import {TABS_ENABLED} from '../../../../build-flags'
import {colors} from 'lib/styles'
import {toShareUrl} from 'lib/strings/url-helpers'
import {useStores} from 'state/index'
import {ReportPostModal, ConfirmModal} from 'state/models/shell-ui'
import {TABS_ENABLED} from 'lib/build-flags'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
@ -36,6 +38,9 @@ export function DropdownButton({
label,
menuWidth,
children,
openToRight = false,
rightOffset = 0,
bottomOffset = 0,
}: {
type?: DropdownButtonType
style?: StyleProp<ViewStyle>
@ -43,6 +48,9 @@ export function DropdownButton({
label?: string
menuWidth?: number
children?: React.ReactNode
openToRight?: boolean
rightOffset?: number
bottomOffset?: number
}) {
const ref = useRef<TouchableOpacity>(null)
@ -59,12 +67,11 @@ export function DropdownButton({
if (!menuWidth) {
menuWidth = 200
}
createDropdownMenu(
pageX + width - menuWidth,
pageY + height,
menuWidth,
items,
)
const newX = openToRight
? pageX + width + rightOffset
: pageX + width - menuWidth
const newY = pageY + height + bottomOffset
createDropdownMenu(newX, newY, menuWidth, items)
},
)
}
@ -97,6 +104,8 @@ export function DropdownButton({
export function PostDropdownBtn({
style,
children,
itemUri,
itemCid,
itemHref,
isAuthor,
onCopyPostText,
@ -104,6 +113,8 @@ export function PostDropdownBtn({
}: {
style?: StyleProp<ViewStyle>
children?: React.ReactNode
itemUri: string
itemCid: string
itemHref: string
itemTitle: string
isAuthor: boolean
@ -140,7 +151,7 @@ export function PostDropdownBtn({
icon: 'circle-exclamation',
label: 'Report post',
onPress() {
store.shell.openModal(new ReportPostModal(itemHref))
store.shell.openModal(new ReportPostModal(itemUri, itemCid))
},
},
isAuthor
@ -180,24 +191,14 @@ function createDropdownMenu(
const onOuterPress = () => sibling.destroy()
const sibling = new RootSiblings(
(
<>
<TouchableWithoutFeedback onPress={onOuterPress}>
<View style={styles.bg} />
</TouchableWithoutFeedback>
<View style={[styles.menu, {left: x, top: y, width}]}>
{items.map((item, index) => (
<TouchableOpacity
key={index}
style={[styles.menuItem]}
onPress={() => onPressItem(index)}>
{item.icon && (
<FontAwesomeIcon style={styles.icon} icon={item.icon} />
)}
<Text style={styles.label}>{item.label}</Text>
</TouchableOpacity>
))}
</View>
</>
<DropdownItems
onOuterPress={onOuterPress}
x={x}
y={y}
width={width}
items={items}
onPressItem={onPressItem}
/>
),
)
return sibling
@ -241,3 +242,55 @@ const styles = StyleSheet.create({
fontSize: 18,
},
})
type DropDownItemProps = {
onOuterPress: () => void
x: number
y: number
width: number
items: DropdownItem[]
onPressItem: (index: number) => void
}
const DropdownItems = ({
onOuterPress,
x,
y,
width,
items,
onPressItem,
}: DropDownItemProps) => {
const pal = usePalette('default')
const theme = useTheme()
const dropDownBackgroundColor =
theme.colorScheme === 'dark' ? pal.btn : pal.view
return (
<>
<TouchableWithoutFeedback onPress={onOuterPress}>
<View style={[styles.bg]} />
</TouchableWithoutFeedback>
<View
style={[
styles.menu,
{left: x, top: y, width},
dropDownBackgroundColor,
]}>
{items.map((item, index) => (
<TouchableOpacity
key={index}
style={[styles.menuItem]}
onPress={() => onPressItem(index)}>
{item.icon && (
<FontAwesomeIcon
style={styles.icon}
icon={item.icon}
color={pal.text.color as string}
/>
)}
<Text style={[styles.label, pal.text]}>{item.label}</Text>
</TouchableOpacity>
))}
</View>
</>
)
}

View file

@ -2,8 +2,8 @@ import React from 'react'
import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
import {Text} from '../text/Text'
import {Button, ButtonType} from './Button'
import {useTheme} from '../../../lib/ThemeContext'
import {choose} from '../../../../lib/functions'
import {useTheme} from 'lib/ThemeContext'
import {choose} from 'lib/functions'
export function RadioButton({
type = 'default-light',

View file

@ -2,7 +2,7 @@ import React, {useState} from 'react'
import {View} from 'react-native'
import {RadioButton} from './RadioButton'
import {ButtonType} from './Button'
import {s} from '../../../lib/styles'
import {s} from 'lib/styles'
export interface RadioGroupItem {
label: string

View file

@ -2,9 +2,9 @@ import React from 'react'
import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
import {Text} from '../text/Text'
import {Button, ButtonType} from './Button'
import {useTheme} from '../../../lib/ThemeContext'
import {choose} from '../../../../lib/functions'
import {colors} from '../../../lib/styles'
import {useTheme} from 'lib/ThemeContext'
import {choose} from 'lib/functions'
import {colors} from 'lib/styles'
export function ToggleButton({
type = 'default-light',

View file

@ -9,7 +9,7 @@ import {
View,
} from 'react-native'
import {clamp} from 'lodash'
import {s} from '../../../lib/styles'
import {s} from 'lib/styles'
interface Props {
panX: Animated.Value
@ -90,6 +90,7 @@ export function HorzSwipe({
// swiping right
(diffX < 0 && !canSwipeRight)
) {
panX.setValue(0)
return
}
@ -119,6 +120,7 @@ export function HorzSwipe({
toValue: final,
duration: 100,
useNativeDriver,
isInteraction: false,
}).start(() => {
onSwipeEnd?.(final)
panX.flattenOffset()
@ -130,6 +132,7 @@ export function HorzSwipe({
toValue: 0,
duration: 100,
useNativeDriver,
isInteraction: false,
}).start(() => {
panX.flattenOffset()
panX.setValue(0)

View file

@ -9,7 +9,7 @@ import {
View,
} from 'react-native'
import {clamp} from 'lodash'
import {s} from '../../../lib/styles'
import {s} from 'lib/styles'
export enum Dir {
None,

View file

@ -1,125 +1,59 @@
import React, {useState, useEffect} from 'react'
import {
Image,
ImageStyle,
LayoutChangeEvent,
StyleProp,
StyleSheet,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native'
import {Text} from '../text/Text'
import {useTheme} from '../../../lib/ThemeContext'
import {usePalette} from '../../../lib/hooks/usePalette'
import {DELAY_PRESS_IN} from './constants'
import React from 'react'
import {StyleProp, StyleSheet, TouchableOpacity, ViewStyle} from 'react-native'
import Image, {OnLoadEvent} from 'view/com/util/images/Image'
import {clamp} from 'lib/numbers'
const MAX_HEIGHT = 300
interface Dim {
width: number
height: number
}
export const DELAY_PRESS_IN = 500
const MIN_ASPECT_RATIO = 0.33 // 1/3
const MAX_ASPECT_RATIO = 5 // 5/1
export function AutoSizedImage({
uri,
onPress,
onLongPress,
onPressIn,
style,
containerStyle,
children = null,
}: {
uri: string
onPress?: () => void
onLongPress?: () => void
style?: StyleProp<ImageStyle>
containerStyle?: StyleProp<ViewStyle>
onPressIn?: () => void
style?: StyleProp<ViewStyle>
children?: React.ReactNode
}) {
const theme = useTheme()
const errPal = usePalette('error')
const [error, setError] = useState<string | undefined>('')
const [imgInfo, setImgInfo] = useState<Dim | undefined>()
const [containerInfo, setContainerInfo] = useState<Dim | undefined>()
useEffect(() => {
let aborted = false
if (!imgInfo) {
Image.getSize(
uri,
(width: number, height: number) => {
if (!aborted) {
setImgInfo({width, height})
}
},
(err: any) => {
if (!aborted) {
setError(String(err))
}
},
)
}
return () => {
aborted = true
}
}, [uri, imgInfo])
const onLayout = (evt: LayoutChangeEvent) => {
setContainerInfo({
width: evt.nativeEvent.layout.width,
height: evt.nativeEvent.layout.height,
})
}
let calculatedStyle: StyleProp<ImageStyle> | undefined
if (imgInfo && containerInfo) {
// imgInfo.height / imgInfo.width = x / containerInfo.width
// x = imgInfo.height / imgInfo.width * containerInfo.width
calculatedStyle = {
height: Math.min(
MAX_HEIGHT,
(imgInfo.height / imgInfo.width) * containerInfo.width,
const [aspectRatio, setAspectRatio] = React.useState<number>(1)
const onLoad = (e: OnLoadEvent) => {
setAspectRatio(
clamp(
e.nativeEvent.width / e.nativeEvent.height,
MIN_ASPECT_RATIO,
MAX_ASPECT_RATIO,
),
}
)
}
return (
<View style={style}>
<TouchableOpacity
onPress={onPress}
onLongPress={onLongPress}
delayPressIn={DELAY_PRESS_IN}>
{error ? (
<View style={[styles.errorContainer, errPal.view, containerStyle]}>
<Text style={errPal.text}>{error}</Text>
</View>
) : calculatedStyle ? (
<View style={[styles.container, containerStyle]}>
<Image style={calculatedStyle} source={{uri}} />
</View>
) : (
<View
style={[
style,
styles.placeholder,
{backgroundColor: theme.palette.default.backgroundLight},
]}
onLayout={onLayout}
/>
)}
</TouchableOpacity>
</View>
<TouchableOpacity
onPress={onPress}
onLongPress={onLongPress}
onPressIn={onPressIn}
delayPressIn={DELAY_PRESS_IN}
style={[styles.container, style]}>
<Image
style={[styles.image, {aspectRatio}]}
source={{uri}}
onLoad={onLoad}
/>
{children}
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
placeholder: {
width: '100%',
aspectRatio: 1,
},
errorContainer: {
paddingHorizontal: 12,
paddingVertical: 8,
},
container: {
overflow: 'hidden',
},
image: {
width: '100%',
},
})

View file

@ -0,0 +1,12 @@
import React from 'react'
import FastImage, {FastImageProps, Source} from 'react-native-fast-image'
export default FastImage
export type {OnLoadEvent, ImageStyle, Source} from 'react-native-fast-image'
export function HighPriorityImage({source, ...props}: FastImageProps) {
const updatedSource = {
uri: typeof source === 'object' && source ? source.uri : '',
priority: FastImage.priority.high,
} as Source
return <FastImage source={updatedSource} {...props} />
}

View file

@ -0,0 +1,11 @@
import {
Image,
NativeSyntheticEvent,
ImageLoadEventData,
ImageSourcePropType,
} from 'react-native'
export default Image
export const HighPriorityImage = Image
export type OnLoadEvent = NativeSyntheticEvent<ImageLoadEventData>
export type Source = ImageSourcePropType
export type {ImageStyle} from 'react-native'

View file

@ -1,12 +1,12 @@
import React from 'react'
import {
Image,
StyleProp,
StyleSheet,
TouchableWithoutFeedback,
View,
ViewStyle,
} from 'react-native'
import Image from 'view/com/util/images/Image'
export function ImageHorzList({
uris,

View file

@ -1,7 +1,5 @@
import React from 'react'
import {
Image,
ImageStyle,
LayoutChangeEvent,
StyleProp,
StyleSheet,
@ -9,7 +7,9 @@ import {
View,
ViewStyle,
} from 'react-native'
import {DELAY_PRESS_IN} from './constants'
import Image, {ImageStyle} from 'view/com/util/images/Image'
export const DELAY_PRESS_IN = 500
interface Dim {
width: number
@ -23,12 +23,14 @@ export function ImageLayoutGrid({
uris,
onPress,
onLongPress,
onPressIn,
style,
}: {
type: ImageLayoutGridType
uris: string[]
onPress?: (index: number) => void
onLongPress?: (index: number) => void
onPressIn?: (index: number) => void
style?: StyleProp<ViewStyle>
}) {
const [containerInfo, setContainerInfo] = React.useState<Dim | undefined>()
@ -47,6 +49,7 @@ export function ImageLayoutGrid({
type={type}
uris={uris}
onPress={onPress}
onPressIn={onPressIn}
onLongPress={onLongPress}
containerInfo={containerInfo}
/>
@ -60,15 +63,17 @@ function ImageLayoutGridInner({
uris,
onPress,
onLongPress,
onPressIn,
containerInfo,
}: {
type: ImageLayoutGridType
uris: string[]
onPress?: (index: number) => void
onLongPress?: (index: number) => void
onPressIn?: (index: number) => void
containerInfo: Dim
}) {
const size1 = React.useMemo<ImageStyle>(() => {
const size1 = React.useMemo<StyleProp<ImageStyle>>(() => {
if (type === 'three') {
const size = (containerInfo.width - 10) / 3
return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
@ -77,7 +82,7 @@ function ImageLayoutGridInner({
return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
}
}, [type, containerInfo])
const size2 = React.useMemo<ImageStyle>(() => {
const size2 = React.useMemo<StyleProp<ImageStyle>>(() => {
if (type === 'three') {
const size = ((containerInfo.width - 10) / 3) * 2 + 5
return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
@ -93,6 +98,7 @@ function ImageLayoutGridInner({
<TouchableOpacity
delayPressIn={DELAY_PRESS_IN}
onPress={() => onPress?.(0)}
onPressIn={() => onPressIn?.(0)}
onLongPress={() => onLongPress?.(0)}>
<Image source={{uri: uris[0]}} style={size1} />
</TouchableOpacity>
@ -100,6 +106,7 @@ function ImageLayoutGridInner({
<TouchableOpacity
delayPressIn={DELAY_PRESS_IN}
onPress={() => onPress?.(1)}
onPressIn={() => onPressIn?.(1)}
onLongPress={() => onLongPress?.(1)}>
<Image source={{uri: uris[1]}} style={size1} />
</TouchableOpacity>
@ -112,6 +119,7 @@ function ImageLayoutGridInner({
<TouchableOpacity
delayPressIn={DELAY_PRESS_IN}
onPress={() => onPress?.(0)}
onPressIn={() => onPressIn?.(0)}
onLongPress={() => onLongPress?.(0)}>
<Image source={{uri: uris[0]}} style={size2} />
</TouchableOpacity>
@ -120,6 +128,7 @@ function ImageLayoutGridInner({
<TouchableOpacity
delayPressIn={DELAY_PRESS_IN}
onPress={() => onPress?.(1)}
onPressIn={() => onPressIn?.(1)}
onLongPress={() => onLongPress?.(1)}>
<Image source={{uri: uris[1]}} style={size1} />
</TouchableOpacity>
@ -127,6 +136,7 @@ function ImageLayoutGridInner({
<TouchableOpacity
delayPressIn={DELAY_PRESS_IN}
onPress={() => onPress?.(2)}
onPressIn={() => onPressIn?.(2)}
onLongPress={() => onLongPress?.(2)}>
<Image source={{uri: uris[2]}} style={size1} />
</TouchableOpacity>
@ -141,29 +151,33 @@ function ImageLayoutGridInner({
<TouchableOpacity
delayPressIn={DELAY_PRESS_IN}
onPress={() => onPress?.(0)}
onPressIn={() => onPressIn?.(0)}
onLongPress={() => onLongPress?.(0)}>
<Image source={{uri: uris[0]}} style={size1} />
</TouchableOpacity>
<View style={styles.hSpace} />
<TouchableOpacity
delayPressIn={DELAY_PRESS_IN}
onPress={() => onPress?.(1)}
onLongPress={() => onLongPress?.(1)}>
<Image source={{uri: uris[1]}} style={size1} />
onPress={() => onPress?.(2)}
onPressIn={() => onPressIn?.(2)}
onLongPress={() => onLongPress?.(2)}>
<Image source={{uri: uris[2]}} style={size1} />
</TouchableOpacity>
</View>
<View style={styles.wSpace} />
<View>
<TouchableOpacity
delayPressIn={DELAY_PRESS_IN}
onPress={() => onPress?.(2)}
onLongPress={() => onLongPress?.(2)}>
<Image source={{uri: uris[2]}} style={size1} />
onPress={() => onPress?.(1)}
onPressIn={() => onPressIn?.(1)}
onLongPress={() => onLongPress?.(1)}>
<Image source={{uri: uris[1]}} style={size1} />
</TouchableOpacity>
<View style={styles.hSpace} />
<TouchableOpacity
delayPressIn={DELAY_PRESS_IN}
onPress={() => onPress?.(3)}
onPressIn={() => onPressIn?.(3)}
onLongPress={() => onLongPress?.(3)}>
<Image source={{uri: uris[3]}} style={size1} />
</TouchableOpacity>

View file

@ -1 +0,0 @@
export const DELAY_PRESS_IN = 500

View file

@ -4,7 +4,7 @@ import {
openCropper as openCropperFn,
ImageOrVideo,
} from 'react-native-image-crop-picker'
import {RootStoreModel} from '../../../../../state'
import {RootStoreModel} from 'state/index'
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
export type {PickedMedia} from './types'

View file

@ -1,9 +1,9 @@
/// <reference lib="dom" />
import {CropImageModal} from '../../../../../state/models/shell-ui'
import {CropImageModal} from 'state/models/shell-ui'
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
export type {PickedMedia} from './types'
import {RootStoreModel} from '../../../../../state'
import {RootStoreModel} from 'state/index'
interface PickedFile {
uri: string
@ -31,17 +31,17 @@ export async function openPicker(
export async function openCamera(
_store: RootStoreModel,
opts: CameraOpts,
_opts: CameraOpts,
): Promise<PickedMedia> {
const mediaType = opts.mediaType || 'photo'
// const mediaType = opts.mediaType || 'photo' TODO
throw new Error('TODO')
}
export async function openCropper(
_store: RootStoreModel,
opts: CropperOpts,
_opts: CropperOpts,
): Promise<PickedMedia> {
const mediaType = opts.mediaType || 'photo'
// const mediaType = opts.mediaType || 'photo' TODO
throw new Error('TODO')
}

View file

@ -2,29 +2,21 @@ import React from 'react'
import {TextStyle, StyleProp} from 'react-native'
import {TextLink} from '../Link'
import {Text} from './Text'
import {lh} from '../../../lib/styles'
import {toShortUrl} from '../../../../lib/strings'
import {useTheme, TypographyVariant} from '../../../lib/ThemeContext'
import {usePalette} from '../../../lib/hooks/usePalette'
type TextSlice = {start: number; end: number}
type Entity = {
index: TextSlice
type: string
value: string
}
import {lh} from 'lib/styles'
import {toShortUrl} from 'lib/strings/url-helpers'
import {RichText as RichTextObj, Entity} from 'lib/strings/rich-text'
import {useTheme, TypographyVariant} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
export function RichText({
type = 'md',
text,
entities,
richText,
lineHeight = 1.2,
style,
numberOfLines,
}: {
type?: TypographyVariant
text: string
entities?: Entity[]
richText?: RichTextObj
lineHeight?: number
style?: StyleProp<TextStyle>
numberOfLines?: number
@ -32,6 +24,12 @@ export function RichText({
const theme = useTheme()
const pal = usePalette('default')
const lineHeightStyle = lh(theme, type, lineHeight)
if (!richText) {
return null
}
const {text, entities} = richText
if (!entities?.length) {
if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) {
style = {

View file

@ -1,7 +1,7 @@
import React from 'react'
import {Text as RNText, TextProps} from 'react-native'
import {s} from '../../../lib/styles'
import {useTheme, TypographyVariant} from '../../../lib/ThemeContext'
import {s} from 'lib/styles'
import {useTheme, TypographyVariant} from 'lib/ThemeContext'
export type CustomTextProps = TextProps & {
type?: TypographyVariant

View file

@ -65,6 +65,8 @@ import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
import {faX} from '@fortawesome/free-solid-svg-icons/faX'
import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay'
import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
export function setup() {
library.add(
@ -133,5 +135,7 @@ export function setup() {
faTrashCan,
faX,
faXmark,
faPlay,
faPause,
)
}

View file

@ -1,93 +0,0 @@
import React, {createContext, useContext, useMemo} from 'react'
import {TextStyle, useColorScheme, ViewStyle} from 'react-native'
import {darkTheme, defaultTheme} from './themes'
export type ColorScheme = 'light' | 'dark'
export type PaletteColorName =
| 'default'
| 'primary'
| 'secondary'
| 'inverted'
| 'error'
export type PaletteColor = {
background: string
backgroundLight: string
text: string
textLight: string
textInverted: string
link: string
border: string
borderDark: string
icon: string
[k: string]: string
}
export type Palette = Record<PaletteColorName, PaletteColor>
export type ShapeName = 'button' | 'bigButton' | 'smallButton'
export type Shapes = Record<ShapeName, ViewStyle>
export type TypographyVariant =
| 'xl-thin'
| 'xl'
| 'xl-medium'
| 'xl-bold'
| 'xl-heavy'
| 'lg-thin'
| 'lg'
| 'lg-medium'
| 'lg-bold'
| 'lg-heavy'
| 'md-thin'
| 'md'
| 'md-medium'
| 'md-bold'
| 'md-heavy'
| 'sm-thin'
| 'sm'
| 'sm-medium'
| 'sm-bold'
| 'sm-heavy'
| 'xs-thin'
| 'xs'
| 'xs-medium'
| 'xs-bold'
| 'xs-heavy'
| 'title-xl'
| 'title-lg'
| 'title'
| 'title-sm'
| 'post-text-lg'
| 'post-text'
| 'button'
| 'mono'
export type Typography = Record<TypographyVariant, TextStyle>
export interface Theme {
colorScheme: ColorScheme
palette: Palette
shapes: Shapes
typography: Typography
}
export interface ThemeProviderProps {
theme?: ColorScheme
}
export const ThemeContext = createContext<Theme>(defaultTheme)
export const useTheme = () => useContext(ThemeContext)
export const ThemeProvider: React.FC<ThemeProviderProps> = ({
theme,
children,
}) => {
const colorScheme = useColorScheme()
const value = useMemo(
() => ((theme || colorScheme) === 'dark' ? darkTheme : defaultTheme),
[colorScheme, theme],
)
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}

View file

@ -1,11 +0,0 @@
import {StyleProp} from 'react-native'
export function addStyle<T>(
base: StyleProp<T>,
addedStyle: StyleProp<T>,
): StyleProp<T> {
if (Array.isArray(base)) {
return base.concat([addedStyle])
}
return [base, addedStyle]
}

View file

@ -1,5 +0,0 @@
import {ImageSourcePropType} from 'react-native'
export const DEF_AVATAR: ImageSourcePropType = require('../../../public/img/default-avatar.jpg')
export const TABS_EXPLAINER: ImageSourcePropType = require('../../../public/img/tabs-explainer.jpg')
export const CLOUD_SPLASH: ImageSourcePropType = require('../../../public/img/cloud-splash.png')

View file

@ -1,7 +0,0 @@
import {ImageSourcePropType} from 'react-native'
export const DEF_AVATAR: ImageSourcePropType = {uri: '/img/default-avatar.jpg'}
export const TABS_EXPLAINER: ImageSourcePropType = {
uri: '/img/tabs-explainer.jpg',
}
export const CLOUD_SPLASH: ImageSourcePropType = {uri: '/img/cloud-splash.png'}

View file

@ -1,12 +0,0 @@
import * as React from 'react'
import {Animated} from 'react-native'
export function useAnimatedValue(initialValue: number) {
const lazyRef = React.useRef<Animated.Value>()
if (lazyRef.current === undefined) {
lazyRef.current = new Animated.Value(initialValue)
}
return lazyRef.current as Animated.Value
}

View file

@ -1,25 +0,0 @@
import {useState} from 'react'
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
import {RootStoreModel} from '../../../state'
export type OnScrollCb = (
event: NativeSyntheticEvent<NativeScrollEvent>,
) => void
export function useOnMainScroll(store: RootStoreModel) {
let [lastY, setLastY] = useState(0)
let isMinimal = store.shell.minimalShellMode
return function onMainScroll(event: NativeSyntheticEvent<NativeScrollEvent>) {
const y = event.nativeEvent.contentOffset.y
const dy = y - (lastY || 0)
setLastY(y)
if (!isMinimal && y > 10 && dy > 10) {
store.shell.setMinimalShellMode(true)
isMinimal = true
} else if (isMinimal && (y <= 10 || dy < -10)) {
store.shell.setMinimalShellMode(false)
isMinimal = false
}
}
}

View file

@ -1,48 +0,0 @@
import {TextStyle, ViewStyle} from 'react-native'
import {useTheme, PaletteColorName, PaletteColor} from '../ThemeContext'
export interface UsePaletteValue {
colors: PaletteColor
view: ViewStyle
btn: ViewStyle
border: ViewStyle
borderDark: ViewStyle
text: TextStyle
textLight: TextStyle
textInverted: TextStyle
link: TextStyle
icon: TextStyle
}
export function usePalette(color: PaletteColorName): UsePaletteValue {
const palette = useTheme().palette[color]
return {
colors: palette,
view: {
backgroundColor: palette.background,
},
btn: {
backgroundColor: palette.backgroundLight,
},
border: {
borderColor: palette.border,
},
borderDark: {
borderColor: palette.borderDark,
},
text: {
color: palette.text,
},
textLight: {
color: palette.textLight,
},
textInverted: {
color: palette.textInverted,
},
link: {
color: palette.link,
},
icon: {
color: palette.icon,
},
}
}

View file

@ -1,529 +0,0 @@
import React from 'react'
import {StyleProp, TextStyle, ViewStyle} from 'react-native'
import Svg, {Path, Rect} from 'react-native-svg'
export function GridIcon({
style,
solid,
}: {
style?: StyleProp<ViewStyle>
solid?: boolean
}) {
const DIM = 4
const ARC = 2
return (
<Svg width="24" height="24" style={style}>
<Path
d={`M4,1 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`}
strokeWidth={2}
stroke="#000"
fill={solid ? '#000' : undefined}
/>
<Path
d={`M16,1 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`}
strokeWidth={2}
stroke="#000"
fill={solid ? '#000' : undefined}
/>
<Path
d={`M4,13 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`}
strokeWidth={2}
stroke="#000"
fill={solid ? '#000' : undefined}
/>
<Path
d={`M16,13 h${DIM} a${ARC},${ARC} 0 0 1 ${ARC},${ARC} v${DIM} a${ARC},${ARC} 0 0 1 -${ARC},${ARC} h-${DIM} a${ARC},${ARC} 0 0 1 -${ARC},-${ARC} v-${DIM} a${ARC},${ARC} 0 0 1 ${ARC},-${ARC} z`}
strokeWidth={2}
stroke="#000"
fill={solid ? '#000' : undefined}
/>
</Svg>
)
}
export function GridIconSolid({style}: {style?: StyleProp<ViewStyle>}) {
return <GridIcon style={style} solid />
}
export function HomeIcon({
style,
size,
strokeWidth = 4,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
viewBox="0 0 48 48"
width={size || 24}
height={size || 24}
stroke="currentColor"
fill="none"
style={style}>
<Path
strokeWidth={strokeWidth}
d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z"
/>
</Svg>
)
}
export function HomeIconSolid({
style,
size,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
}) {
return (
<Svg
viewBox="0 0 48 48"
width={size || 24}
height={size || 24}
stroke="currentColor"
style={style}>
<Path
strokeWidth={2}
fill="currentColor"
d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z"
/>
</Svg>
)
}
// Copyright (c) 2020 Refactoring UI Inc.
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
export function MagnifyingGlassIcon({
style,
size,
strokeWidth = 2,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={strokeWidth}
stroke="currentColor"
width={size || 24}
height={size || 24}
style={style}>
<Path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</Svg>
)
}
// https://github.com/Remix-Design/RemixIcon/blob/master/License
export function BellIcon({
style,
size,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
width={size || 24}
height={size || 24}
style={style}>
<Path fill="none" d="M0 0h24v24H0z" />
<Path
fill="currentColor"
d="M20 17h2v2H2v-2h2v-7a8 8 0 1 1 16 0v7zm-2 0v-7a6 6 0 1 0-12 0v7h12zm-9 4h6v2H9v-2z"
/>
</Svg>
)
}
// https://github.com/Remix-Design/RemixIcon/blob/master/License
export function BellIconSolid({
style,
size,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
width={size || 24}
height={size || 24}
style={style}>
<Path fill="none" d="M0 0h24v24H0z" />
<Path
fill="currentColor"
d="M 20 17 L 22 17 L 22 19 L 2 19 L 2 17 L 4 17 L 4 10 C 4 3.842 10.667 -0.007 16 3.072 C 18.475 4.501 20 7.142 20 10 L 20 17 Z M 9 21 L 15 21 L 15 23 L 9 23 L 9 21 Z"
/>
</Svg>
)
}
export function CogIcon({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
width={size || 32}
height={size || 32}
strokeWidth={strokeWidth}
stroke="currentColor"
style={style}>
<Path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<Path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</Svg>
)
}
// Copyright (c) 2020 Refactoring UI Inc.
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
export function UserIcon({
style,
size,
strokeWidth = 1.5,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
width={size || 32}
height={size || 32}
strokeWidth={strokeWidth}
stroke="currentColor"
style={style}>
<Path
strokeLinecap="round"
strokeLinejoin="round"
d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z"
/>
</Svg>
)
}
// Copyright (c) 2020 Refactoring UI Inc.
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
export function UserGroupIcon({
style,
size,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
width={size || 32}
height={size || 32}
strokeWidth={2}
stroke="currentColor"
style={style}>
<Path
strokeLinecap="round"
strokeLinejoin="round"
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
/>
</Svg>
)
}
export function RepostIcon({
style,
size = 24,
strokeWidth = 1.5,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth: number
}) {
return (
<Svg viewBox="0 0 24 24" width={size} height={size} style={style}>
<Path
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinejoin="round"
fill="none"
d="M 14.437 17.081 L 5.475 17.095 C 4.7 17.095 4.072 16.467 4.072 15.692 L 4.082 5.65 L 1.22 9.854 M 4.082 5.65 L 7.006 9.854 M 9.859 5.65 L 18.625 5.654 C 19.4 5.654 20.028 6.282 20.028 7.057 L 20.031 17.081 L 17.167 12.646 M 20.031 17.081 L 22.866 12.646"
/>
</Svg>
)
}
// Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc.
export function HeartIcon({
style,
size = 24,
strokeWidth = 1.5,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth: number
}) {
return (
<Svg viewBox="0 0 24 24" width={size} height={size} style={style}>
<Path
strokeWidth={strokeWidth}
stroke="currentColor"
fill="none"
d="M 3.859 13.537 L 10.918 20.127 C 11.211 20.4 11.598 20.552 12 20.552 C 12.402 20.552 12.789 20.4 13.082 20.127 L 20.141 13.537 C 21.328 12.431 22 10.88 22 9.259 L 22 9.033 C 22 6.302 20.027 3.974 17.336 3.525 C 15.555 3.228 13.742 3.81 12.469 5.084 L 12 5.552 L 11.531 5.084 C 10.258 3.81 8.445 3.228 6.664 3.525 C 3.973 3.974 2 6.302 2 9.033 L 2 9.259 C 2 10.88 2.672 12.431 3.859 13.537 Z"
/>
</Svg>
)
}
// Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc.
export function HeartIconSolid({
style,
size = 24,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
}) {
return (
<Svg viewBox="0 0 24 24" width={size} height={size} style={style}>
<Path
fill="currentColor"
stroke="currentColor"
strokeWidth={1}
d="M 3.859 13.537 L 10.918 20.127 C 11.211 20.4 11.598 20.552 12 20.552 C 12.402 20.552 12.789 20.4 13.082 20.127 L 20.141 13.537 C 21.328 12.431 22 10.88 22 9.259 L 22 9.033 C 22 6.302 20.027 3.974 17.336 3.525 C 15.555 3.228 13.742 3.81 12.469 5.084 L 12 5.552 L 11.531 5.084 C 10.258 3.81 8.445 3.228 6.664 3.525 C 3.973 3.974 2 6.302 2 9.033 L 2 9.259 C 2 10.88 2.672 12.431 3.859 13.537 Z"
/>
</Svg>
)
}
export function UpIcon({
style,
size,
strokeWidth = 1.3,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
strokeWidth: number
}) {
return (
<Svg
viewBox="0 0 14 14"
width={size || 24}
height={size || 24}
style={style}>
<Path
strokeWidth={strokeWidth}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
d="M 7 3 L 2 8 L 4.5 8 L 4.5 11.5 L 9.5 11.5 L 9.5 8 L 12 8 L 7 3 Z"
/>
</Svg>
)
}
export function UpIconSolid({
style,
size,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
}) {
return (
<Svg
viewBox="0 0 14 14"
width={size || 24}
height={size || 24}
style={style}>
<Path
strokeWidth={1.3}
stroke="currentColor"
fill="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
d="M 7 3 L 2 8 L 4.5 8 L 4.5 11.5 L 9.5 11.5 L 9.5 8 L 12 8 L 7 3 Z"
/>
</Svg>
)
}
export function DownIcon({
style,
size,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
}) {
return (
<Svg
viewBox="0 0 14 14"
width={size || 24}
height={size || 24}
style={style}>
<Path
strokeWidth={1.3}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
d="M 7 11.5 L 2 6.5 L 4.5 6.5 L 4.5 3 L 9.5 3 L 9.5 6.5 L 12 6.5 L 7 11.5 Z"
/>
</Svg>
)
}
export function DownIconSolid({
style,
size,
}: {
style?: StyleProp<ViewStyle>
size?: string | number
}) {
return (
<Svg
viewBox="0 0 14 14"
width={size || 24}
height={size || 24}
style={style}>
<Path
strokeWidth={1.3}
stroke="currentColor"
fill="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
d="M 7 11.5 L 2 6.5 L 4.5 6.5 L 4.5 3 L 9.5 3 L 9.5 6.5 L 12 6.5 L 7 11.5 Z"
/>
</Svg>
)
}
// Copyright (c) 2020 Refactoring UI Inc.
// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
export function CommentBottomArrow({
style,
size,
strokeWidth = 1.3,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth?: number
}) {
let color = 'currentColor'
if (
style &&
typeof style === 'object' &&
'color' in style &&
typeof style.color === 'string'
) {
color = style.color
}
return (
<Svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={strokeWidth || 2.5}
stroke={color}
width={size || 24}
height={size || 24}
style={style}>
<Path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.068.157 2.148.279 3.238.364.466.037.893.281 1.153.671L12 21l2.652-3.978c.26-.39.687-.634 1.153-.67 1.09-.086 2.17-.208 3.238-.365 1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
/>
</Svg>
)
}
export function SquareIcon({
style,
size,
strokeWidth = 1.3,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={strokeWidth || 1}
stroke="currentColor"
width={size || 24}
height={size || 24}
style={style}>
<Rect x="6" y="6" width="12" height="12" strokeLinejoin="round" />
</Svg>
)
}
export function RectWideIcon({
style,
size,
strokeWidth = 1.3,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={strokeWidth || 1}
stroke="currentColor"
width={size || 24}
height={size || 24}
style={style}>
<Rect x="4" y="6" width="16" height="12" strokeLinejoin="round" />
</Svg>
)
}
export function RectTallIcon({
style,
size,
strokeWidth = 1.3,
}: {
style?: StyleProp<TextStyle>
size?: string | number
strokeWidth?: number
}) {
return (
<Svg
fill="none"
viewBox="0 0 24 24"
strokeWidth={strokeWidth || 1}
stroke="currentColor"
width={size || 24}
height={size || 24}
style={style}>
<Rect x="6" y="4" width="12" height="16" strokeLinejoin="round" />
</Svg>
)
}

Some files were not shown because too many files have changed in this diff Show more