Merge branch 'main' into upload-image

zio/stable
João Ferreiro 2022-11-28 16:56:05 +00:00
commit c5f3200d6b
27 changed files with 424 additions and 428 deletions

View File

@ -31,6 +31,11 @@ describe('extractEntities', () => {
'start middle end.com/foo/bar?baz=bux#hash',
'newline1.com\nnewline2.com',
'not.. a..url ..here',
'e.g.',
'something-cool.jpg',
'website.com.jpg',
'e.g./foo',
'website.com.jpg/foo',
]
interface Output {
type: string
@ -80,6 +85,11 @@ describe('extractEntities', () => {
{type: 'link', value: 'newline2.com', noScheme: true},
],
[],
[],
[],
[],
[],
[],
]
it('correctly handles a set of text inputs', () => {
for (let i = 0; i < inputs.length; i++) {
@ -145,6 +155,12 @@ describe('detectLinkables', () => {
'start middle end.com/foo/bar?baz=bux#hash',
'newline1.com\nnewline2.com',
'not.. a..url ..here',
'e.g.',
'e.g. real.com fake.notreal',
'something-cool.jpg',
'website.com.jpg',
'e.g./foo',
'website.com.jpg/foo',
]
const outputs = [
['no linkable'],
@ -171,6 +187,12 @@ describe('detectLinkables', () => {
['start middle ', {link: 'end.com/foo/bar?baz=bux#hash'}],
[{link: 'newline1.com'}, '\n', {link: 'newline2.com'}],
['not.. a..url ..here'],
['e.g.'],
['e.g. ', {link: 'real.com'}, ' fake.notreal'],
['something-cool.jpg'],
['website.com.jpg'],
['e.g./foo'],
['website.com.jpg/foo'],
]
it('correctly handles a set of text inputs', () => {
for (let i = 0; i < inputs.length; i++) {

View File

@ -48,7 +48,8 @@
"react-native-svg": "^12.4.0",
"react-native-tab-view": "^3.3.0",
"react-native-url-polyfill": "^1.3.0",
"react-native-web": "^0.17.7"
"react-native-web": "^0.17.7",
"tlds": "^1.234.0"
},
"devDependencies": {
"@babel/core": "^7.12.9",

View File

@ -2,7 +2,7 @@ import React, {useState, useEffect} from 'react'
import * as view from './view/index'
import {RootStoreModel, setupState, RootStoreProvider} from './state'
import {DesktopWebShell} from './view/shell/desktop-web'
import Toast from './view/com/util/Toast'
import Toast from 'react-native-root-toast'
function App() {
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(

View File

@ -1,6 +1,7 @@
import {AtUri} from '../third-party/uri'
import {Entity} from '../third-party/api/src/client/types/app/bsky/feed/post'
import {PROD_SERVICE} from '../state'
import TLDs from 'tlds'
export const MAX_DISPLAY_NAME = 64
export const MAX_DESCRIPTION = 256
@ -57,6 +58,14 @@ export function ago(date: number | string | Date): string {
}
}
export function isValidDomain(str: string): boolean {
return !!TLDs.find(tld => {
let i = str.lastIndexOf(tld)
if (i === -1) return false
return str.charAt(i - 1) === '.' && i === str.length - tld.length
})
}
export function extractEntities(
text: string,
knownHandles?: Set<string>,
@ -85,10 +94,14 @@ export function extractEntities(
{
// links
const re =
/(^|\s)((https?:\/\/[\S]+)|([a-z][a-z0-9]*(\.[a-z0-9]+)+[\S]*))(\b)/dg
/(^|\s)((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))(\b)/dg
while ((match = re.exec(text))) {
let value = match[2]
if (!value.startsWith('http')) {
const domain = match.groups?.domain
if (!domain || !isValidDomain(domain)) {
continue
}
value = `https://${value}`
}
ents.push({
@ -110,7 +123,7 @@ interface DetectedLink {
type DetectedLinkable = string | DetectedLink
export function detectLinkables(text: string): DetectedLinkable[] {
const re =
/((^|\s)@[a-z0-9\.-]*)|((^|\s)https?:\/\/[\S]+)|((^|\s)[a-z][a-z0-9]*(\.[a-z0-9]+)+[\S]*)/gi
/((^|\s)@[a-z0-9\.-]*)|((^|\s)https?:\/\/[\S]+)|((^|\s)(?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*)/gi
const segments = []
let match
let start = 0
@ -118,6 +131,10 @@ export function detectLinkables(text: string): DetectedLinkable[] {
let matchIndex = match.index
let matchValue = match[0]
if (match.groups?.domain && !isValidDomain(match.groups?.domain)) {
continue
}
if (/\s/.test(matchValue)) {
// HACK
// skip the starting space

View File

@ -7,6 +7,8 @@ import * as apilib from '../lib/api'
import {cleanError} from '../../lib/strings'
import {isObj, hasProp} from '../lib/type-guards'
const PAGE_SIZE = 30
type FeedItem = GetTimeline.FeedItem | GetAuthorFeed.FeedItem
type FeedItemWithThreadMeta = FeedItem & {
_isThreadParent?: boolean
@ -166,6 +168,7 @@ export class FeedModel {
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams
hasMore = true
loadMoreCursor: string | undefined
pollCursor: string | undefined
_loadPromise: Promise<void> | undefined
_loadMorePromise: Promise<void> | undefined
_loadLatestPromise: Promise<void> | undefined
@ -300,7 +303,7 @@ export class FeedModel {
const res = await this._getFeed({limit: 1})
this.setHasNewLatest(
res.data.feed[0] &&
(this.feed.length === 0 || res.data.feed[0].uri !== this.feed[0]?.uri),
(this.feed.length === 0 || res.data.feed[0].uri !== this.pollCursor),
)
}
@ -341,7 +344,7 @@ export class FeedModel {
private async _initialLoad(isRefreshing = false) {
this._xLoading(isRefreshing)
try {
const res = await this._getFeed()
const res = await this._getFeed({limit: PAGE_SIZE})
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
@ -352,7 +355,7 @@ export class FeedModel {
private async _loadLatest() {
this._xLoading()
try {
const res = await this._getFeed()
const res = await this._getFeed({limit: PAGE_SIZE})
this._prependAll(res)
this._xIdle()
} catch (e: any) {
@ -368,6 +371,7 @@ export class FeedModel {
try {
const res = await this._getFeed({
before: this.loadMoreCursor,
limit: PAGE_SIZE,
})
this._appendAll(res)
this._xIdle()
@ -402,6 +406,7 @@ export class FeedModel {
private _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
this.feed.length = 0
this.pollCursor = res.data.feed[0]?.uri
this._appendAll(res)
}
@ -434,6 +439,7 @@ export class FeedModel {
}
private _prependAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
this.pollCursor = res.data.feed[0]?.uri
let counter = this.feed.length
const toPrepend = []
for (const item of res.data.feed) {
@ -493,8 +499,7 @@ function preprocessFeed(
for (let i = feed.length - 1; i >= 0; i--) {
const item = feed[i] as FeedItemWithThreadMeta
// dont dedup the first item so that polling works properly
if (dedup && i !== 0) {
if (dedup) {
if (reorg.find(item2 => item2.uri === item.uri)) {
continue
}

View File

@ -7,7 +7,7 @@ import {APP_BSKY_GRAPH} from '../../third-party/api'
import {cleanError} from '../../lib/strings'
const UNGROUPABLE_REASONS = ['trend', 'assertion']
const PAGE_SIZE = 30
const MS_60MIN = 1e3 * 60 * 60
export interface GroupedNotification extends ListNotifications.Notification {
@ -242,9 +242,10 @@ export class NotificationsViewModel {
private async _initialLoad(isRefreshing = false) {
this._xLoading(isRefreshing)
try {
const res = await this.rootStore.api.app.bsky.notification.list(
this.params,
)
const params = Object.assign({}, this.params, {
limit: PAGE_SIZE,
})
const res = await this.rootStore.api.app.bsky.notification.list(params)
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
@ -259,6 +260,7 @@ export class NotificationsViewModel {
this._xLoading()
try {
const params = Object.assign({}, this.params, {
limit: PAGE_SIZE,
before: this.loadMoreCursor,
})
const res = await this.rootStore.api.app.bsky.notification.list(params)

View File

@ -1,4 +1,4 @@
import React, {useEffect, useMemo, useState} from 'react'
import React, {useEffect, useMemo, useRef, useState} from 'react'
import {observer} from 'mobx-react-lite'
import {
ActivityIndicator,
@ -17,9 +17,10 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {UserAutocompleteViewModel} from '../../../state/models/user-autocomplete-view'
import {UserLocalPhotosModel} from '../../../state/models/user-local-photos'
import {Autocomplete} from './Autocomplete'
import Toast from '../util/Toast'
import * as Toast from '../util/Toast'
import ProgressCircle from '../util/ProgressCircle'
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'
@ -28,7 +29,6 @@ import {detectLinkables} from '../../../lib/strings'
import {openPicker, openCamera} from 'react-native-image-crop-picker'
const MAX_TEXT_LENGTH = 256
const WARNING_TEXT_LENGTH = 200
const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH
export const ComposePost = observer(function ComposePost({
@ -41,6 +41,7 @@ export const ComposePost = observer(function ComposePost({
onClose: () => void
}) {
const store = useStores()
const textInput = useRef<TextInput>(null)
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState('')
const [text, setText] = useState('')
@ -57,6 +58,22 @@ export const ComposePost = observer(function ComposePost({
useEffect(() => {
autocompleteView.setup()
})
useEffect(() => {
// HACK
// wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
// -prf
let to: NodeJS.Timeout | undefined
if (textInput.current) {
to = setTimeout(() => {
textInput.current?.focus()
}, 250)
}
return () => {
if (to) {
clearTimeout(to)
}
}
}, [textInput.current])
useEffect(() => {
localPhotos.setup()
@ -90,7 +107,10 @@ export const ComposePost = observer(function ComposePost({
}
setIsProcessing(true)
try {
await apilib.post(store, text, replyTo, autocompleteView.knownHandles)
const replyRef = replyTo
? {uri: replyTo.uri, cid: replyTo.cid}
: undefined
await apilib.post(store, text, replyRef, autocompleteView.knownHandles)
} catch (e: any) {
console.error(`Failed to create post: ${e.toString()}`)
setError(
@ -101,13 +121,7 @@ export const ComposePost = observer(function ComposePost({
}
onPost?.()
onClose()
Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`, {
duration: Toast.durations.LONG,
position: Toast.positions.TOP,
shadow: true,
animation: true,
hideOnPress: true,
})
Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
}
const onSelectAutocompleteItem = (item: string) => {
setText(replaceTextAutocompletePrefix(text, item))
@ -115,12 +129,7 @@ export const ComposePost = observer(function ComposePost({
}
const canPost = text.length <= MAX_TEXT_LENGTH
const progressColor =
text.length > DANGER_TEXT_LENGTH
? '#e60000'
: text.length > WARNING_TEXT_LENGTH
? '#f7c600'
: undefined
const progressColor = text.length > DANGER_TEXT_LENGTH ? '#e60000' : undefined
const textDecorated = useMemo(() => {
let i = 0
@ -142,7 +151,7 @@ export const ComposePost = observer(function ComposePost({
<SafeAreaView style={s.flex1}>
<View style={styles.topbar}>
<TouchableOpacity onPress={onPressCancel}>
<Text style={[s.blue3, s.f16]}>Cancel</Text>
<Text style={[s.blue3, s.f18]}>Cancel</Text>
</TouchableOpacity>
<View style={s.flex1} />
{isProcessing ? (
@ -156,7 +165,9 @@ export const ComposePost = observer(function ComposePost({
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={styles.postBtn}>
<Text style={[s.white, s.f16, s.bold]}>Post</Text>
<Text style={[s.white, s.f16, s.bold]}>
{replyTo ? 'Reply' : 'Post'}
</Text>
</LinearGradient>
</TouchableOpacity>
) : (
@ -178,39 +189,46 @@ export const ComposePost = observer(function ComposePost({
</View>
)}
{replyTo ? (
<View>
<Text style={s.gray4}>
Replying to{' '}
<View style={styles.replyToLayout}>
<UserAvatar
handle={replyTo.author.handle}
displayName={replyTo.author.displayName}
size={50}
/>
<View style={styles.replyToPost}>
<TextLink
href={`/profile/${replyTo.author.handle}`}
text={'@' + replyTo.author.handle}
style={[s.bold, s.gray5]}
text={replyTo.author.displayName || replyTo.author.handle}
style={[s.f16, s.bold]}
/>
<Text style={[s.f16, s['lh16-1.3']]} numberOfLines={6}>
{replyTo.text}
</Text>
<View style={styles.replyToPost}>
<Text style={s.gray5}>{replyTo.text}</Text>
</View>
</View>
) : undefined}
<View style={styles.textInputLayout}>
<UserAvatar
handle={store.me.handle || ''}
displayName={store.me.displayName}
size={50}
/>
<TextInput
ref={textInput}
multiline
scrollEnabled
onChangeText={(text: string) => onChangeText(text)}
placeholder={
replyTo
? 'Write your reply'
: photoUris.length === 0
? "What's up?"
: 'Add a comment...'
}
placeholder={replyTo ? 'Write your reply' : "What's up?"}
style={styles.textInput}>
{textDecorated}
</TextInput>
</View>
{photoUris.length !== 0 && (
<View style={styles.selectedImageContainer}>
{photoUris.length !== 0 &&
photoUris.map(item => (
photoUris.map((item, index) => (
<View
key={`selected-image-${index}`}
style={[
styles.selectedImage,
photoUris.length === 1
@ -264,8 +282,9 @@ export const ComposePost = observer(function ComposePost({
style={{color: colors.blue3}}
/>
</TouchableOpacity>
{localPhotos.photos.map(item => (
{localPhotos.photos.map((item, index) => (
<TouchableOpacity
key={`local-image-${index}`}
style={styles.photoButton}
onPress={() => {
setPhotoUris([item.node.image.uri, ...photoUris])
@ -343,9 +362,9 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
paddingTop: 10,
paddingBottom: 5,
paddingBottom: 10,
paddingHorizontal: 5,
height: 50,
height: 55,
},
postBtn: {
borderRadius: 20,
@ -371,19 +390,30 @@ const styles = StyleSheet.create({
justifyContent: 'center',
marginRight: 5,
},
textInputLayout: {
flexDirection: 'row',
flex: 1,
borderTopWidth: 1,
borderTopColor: colors.gray2,
paddingTop: 16,
},
textInput: {
flex: 1,
padding: 5,
fontSize: 21,
fontSize: 18,
marginLeft: 8,
},
replyToLayout: {
flexDirection: 'row',
borderTopWidth: 1,
borderTopColor: colors.gray2,
paddingTop: 16,
paddingBottom: 16,
},
replyToPost: {
paddingHorizontal: 8,
paddingVertical: 6,
borderWidth: 1,
borderColor: colors.gray2,
borderRadius: 6,
marginTop: 5,
marginBottom: 10,
flex: 1,
paddingLeft: 13,
paddingRight: 8,
},
contentCenter: {alignItems: 'center'},
selectedImageContainer: {

View File

@ -1,17 +1,29 @@
import React from 'react'
import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {colors} from '../../lib/styles'
import {useStores} from '../../../state'
import {UserAvatar} from '../util/UserAvatar'
export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
export function ComposePrompt({
noAvi = false,
text = "What's up?",
btn = 'Post',
onPressCompose,
}: {
noAvi?: boolean
text?: string
btn?: string
onPressCompose: () => void
}) {
const store = useStores()
const onPressAvatar = () => {
store.nav.navigate(`/profile/${store.me.handle}`)
}
return (
<TouchableOpacity style={styles.container} onPress={onPressCompose}>
<TouchableOpacity
style={[styles.container, noAvi ? styles.noAviContainer : undefined]}
onPress={onPressCompose}>
{!noAvi ? (
<TouchableOpacity style={styles.avatar} onPress={onPressAvatar}>
<UserAvatar
size={50}
@ -19,11 +31,12 @@ export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
displayName={store.me.displayName}
/>
</TouchableOpacity>
) : undefined}
<View style={styles.textContainer}>
<Text style={styles.text}>What's up?</Text>
<Text style={styles.text}>{text}</Text>
</View>
<View style={styles.btn}>
<Text style={styles.btnText}>Post</Text>
<Text style={styles.btnText}>{btn}</Text>
</View>
</TouchableOpacity>
)
@ -40,6 +53,9 @@ const styles = StyleSheet.create({
alignItems: 'center',
backgroundColor: colors.white,
},
noAviContainer: {
paddingVertical: 14,
},
avatar: {
width: 50,
},

View File

@ -14,7 +14,7 @@ import _omit from 'lodash.omit'
import {ErrorScreen} from '../util/ErrorScreen'
import {Link} from '../util/Link'
import {UserAvatar} from '../util/UserAvatar'
import Toast from '../util/Toast'
import * as Toast from '../util/Toast'
import {useStores} from '../../../state'
import * as apilib from '../../../state/lib/api'
import {
@ -63,10 +63,7 @@ export const SuggestedFollows = observer(
setFollows({[item.did]: res.uri, ...follows})
} catch (e) {
console.log(e)
Toast.show('An issue occurred, please try again.', {
duration: Toast.durations.LONG,
position: Toast.positions.TOP,
})
Toast.show('An issue occurred, please try again.')
}
}
const onPressUnfollow = async (item: SuggestedActor) => {
@ -75,10 +72,7 @@ export const SuggestedFollows = observer(
setFollows(_omit(follows, [item.did]))
} catch (e) {
console.log(e)
Toast.show('An issue occurred, please try again.', {
duration: Toast.durations.LONG,
position: Toast.positions.TOP,
})
Toast.show('An issue occurred, please try again.')
}
}

View File

@ -1,5 +1,5 @@
import React, {useState} from 'react'
import Toast from '../util/Toast'
import * as Toast from '../util/Toast'
import {
ActivityIndicator,
StyleSheet,
@ -71,9 +71,7 @@ export function Component({}: {}) {
},
)
.catch(e => console.error(e)) // an error here is not critical
Toast.show('Scene created', {
position: Toast.positions.TOP,
})
Toast.show('Scene created')
store.shell.closeModal()
store.nav.navigate(`/profile/${fullHandle}`)
} catch (e: any) {

View File

@ -1,5 +1,5 @@
import React, {useState} from 'react'
import Toast from '../util/Toast'
import * as Toast from '../util/Toast'
import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {BottomSheetScrollView, BottomSheetTextInput} from '@gorhom/bottom-sheet'
@ -52,9 +52,7 @@ export function Component({
}
},
)
Toast.show('Profile updated', {
position: Toast.positions.TOP,
})
Toast.show('Profile updated')
onUpdate?.()
store.shell.closeModal()
} catch (e: any) {

View File

@ -1,6 +1,6 @@
import React, {useState, useEffect, useMemo} from 'react'
import {observer} from 'mobx-react-lite'
import Toast from '../util/Toast'
import * as Toast from '../util/Toast'
import {
ActivityIndicator,
FlatList,
@ -83,10 +83,7 @@ export const Component = observer(function Component({
follow.declaration.cid,
)
setCreatedInvites({[follow.did]: assertionUri, ...createdInvites})
Toast.show('Invite sent', {
duration: Toast.durations.LONG,
position: Toast.positions.TOP,
})
Toast.show('Invite sent')
} catch (e) {
setError('There was an issue with the invite. Please try again.')
console.error(e)
@ -119,10 +116,7 @@ export const Component = observer(function Component({
[assertion.uri]: true,
...deletedPendingInvites,
})
Toast.show('Invite removed', {
duration: Toast.durations.LONG,
position: Toast.positions.TOP,
})
Toast.show('Invite removed')
} catch (e) {
setError('There was an issue with the invite. Please try again.')
console.error(e)

View File

@ -7,7 +7,7 @@ import {NotificationsViewItemModel} from '../../../state/models/notifications-vi
import {ConfirmModel} from '../../../state/models/shell-ui'
import {useStores} from '../../../state'
import {ProfileCard} from '../profile/ProfileCard'
import Toast from '../util/Toast'
import * as Toast from '../util/Toast'
import {s, colors, gradients} from '../../lib/styles'
export function InviteAccepter({item}: {item: NotificationsViewItemModel}) {
@ -46,10 +46,7 @@ export function InviteAccepter({item}: {item: NotificationsViewItemModel}) {
},
})
store.me.refreshMemberships()
Toast.show('Invite accepted', {
duration: Toast.durations.LONG,
position: Toast.positions.TOP,
})
Toast.show('Invite accepted')
setConfirmationUri(uri)
}
return (

View File

@ -8,7 +8,7 @@ import {PostThreadViewPostModel} from '../../../state/models/post-thread-view'
import {Link} from '../util/Link'
import {RichText} from '../util/RichText'
import {PostDropdownBtn} from '../util/DropdownBtn'
import Toast from '../util/Toast'
import * as Toast from '../util/Toast'
import {UserAvatar} from '../util/UserAvatar'
import {s, colors} from '../../lib/styles'
import {ago, pluralize} from '../../../lib/strings'
@ -16,6 +16,7 @@ import {useStores} from '../../../state'
import {PostMeta} from '../util/PostMeta'
import {PostEmbeds} from '../util/PostEmbeds'
import {PostCtrls} from '../util/PostCtrls'
import {ComposePrompt} from '../composer/Prompt'
const PARENT_REPLY_LINE_LENGTH = 8
const REPLYING_TO_LINE_LENGTH = 6
@ -78,21 +79,18 @@ export const PostThreadItem = observer(function PostThreadItem({
item.delete().then(
() => {
setDeleted(true)
Toast.show('Post deleted', {
position: Toast.positions.TOP,
})
Toast.show('Post deleted')
},
e => {
console.error(e)
Toast.show('Failed to delete post, please try again', {
position: Toast.positions.TOP,
})
Toast.show('Failed to delete post, please try again')
},
)
}
if (item._isHighlightedPost) {
return (
<>
<View style={styles.outer}>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
@ -152,7 +150,7 @@ export const PostThreadItem = observer(function PostThreadItem({
style={[styles.postText, styles.postTextLarge]}
/>
</View>
<PostEmbeds entities={record.entities} />
<PostEmbeds entities={record.entities} style={s.mb10} />
{item._isHighlightedPost && hasEngagement ? (
<View style={styles.expandedInfo}>
{item.repostCount ? (
@ -160,8 +158,8 @@ export const PostThreadItem = observer(function PostThreadItem({
style={styles.expandedInfoItem}
href={repostsHref}
title={repostsTitle}>
<Text style={[s.gray5, s.semiBold, s.f18]}>
<Text style={[s.bold, s.black, s.f18]}>
<Text style={[s.gray5, s.semiBold, s.f17]}>
<Text style={[s.bold, s.black, s.f17]}>
{item.repostCount}
</Text>{' '}
{pluralize(item.repostCount, 'repost')}
@ -175,8 +173,8 @@ export const PostThreadItem = observer(function PostThreadItem({
style={styles.expandedInfoItem}
href={upvotesHref}
title={upvotesTitle}>
<Text style={[s.gray5, s.semiBold, s.f18]}>
<Text style={[s.bold, s.black, s.f18]}>
<Text style={[s.gray5, s.semiBold, s.f17]}>
<Text style={[s.bold, s.black, s.f17]}>
{item.upvoteCount}
</Text>{' '}
{pluralize(item.upvoteCount, 'upvote')}
@ -189,11 +187,9 @@ export const PostThreadItem = observer(function PostThreadItem({
) : (
<></>
)}
<View style={[s.pl10]}>
<View style={[s.pl10, s.pb5]}>
<PostCtrls
replyCount={item.replyCount}
repostCount={item.repostCount}
upvoteCount={item.upvoteCount}
big
isReposted={!!item.myState.repost}
isUpvoted={!!item.myState.upvote}
onPressReply={onPressReply}
@ -203,6 +199,13 @@ export const PostThreadItem = observer(function PostThreadItem({
</View>
</View>
</View>
<ComposePrompt
noAvi
text="Write your reply"
btn="Reply"
onPressCompose={onPressReply}
/>
</>
)
} else {
return (
@ -345,8 +348,8 @@ const styles = StyleSheet.create({
},
postText: {
fontFamily: 'Helvetica Neue',
fontSize: 17,
lineHeight: 22.1, // 1.3 of 17px
fontSize: 16,
lineHeight: 20.8, // 1.3 of 16px
},
postTextContainer: {
flexDirection: 'row',
@ -371,7 +374,7 @@ const styles = StyleSheet.create({
borderTopWidth: 1,
borderBottomWidth: 1,
marginTop: 5,
marginBottom: 10,
marginBottom: 15,
},
expandedInfoItem: {
marginRight: 10,

View File

@ -10,7 +10,7 @@ import {UserInfoText} from '../util/UserInfoText'
import {PostMeta} from '../util/PostMeta'
import {PostCtrls} from '../util/PostCtrls'
import {RichText} from '../util/RichText'
import Toast from '../util/Toast'
import * as Toast from '../util/Toast'
import {UserAvatar} from '../util/UserAvatar'
import {useStores} from '../../../state'
import {s, colors} from '../../lib/styles'
@ -99,15 +99,11 @@ export const Post = observer(function Post({uri}: {uri: string}) {
item.delete().then(
() => {
setDeleted(true)
Toast.show('Post deleted', {
position: Toast.positions.TOP,
})
Toast.show('Post deleted')
},
e => {
console.error(e)
Toast.show('Failed to delete post, please try again', {
position: Toast.positions.TOP,
})
Toast.show('Failed to delete post, please try again')
},
)
}
@ -196,7 +192,7 @@ const styles = StyleSheet.create({
},
postText: {
fontFamily: 'Helvetica Neue',
fontSize: 17,
lineHeight: 22.1, // 1.3 of 17px
fontSize: 16,
lineHeight: 20.8, // 1.3 of 16px
},
})

View File

@ -11,7 +11,7 @@ import {PostMeta} from '../util/PostMeta'
import {PostCtrls} from '../util/PostCtrls'
import {PostEmbeds} from '../util/PostEmbeds'
import {RichText} from '../util/RichText'
import Toast from '../util/Toast'
import * as Toast from '../util/Toast'
import {UserAvatar} from '../util/UserAvatar'
import {s, colors} from '../../lib/styles'
import {useStores} from '../../../state'
@ -70,15 +70,11 @@ export const FeedItem = observer(function FeedItem({
item.delete().then(
() => {
setDeleted(true)
Toast.show('Post deleted', {
position: Toast.positions.TOP,
})
Toast.show('Post deleted')
},
e => {
console.error(e)
Toast.show('Failed to delete post, please try again', {
position: Toast.positions.TOP,
})
Toast.show('Failed to delete post, please try again')
},
)
}
@ -254,7 +250,7 @@ const styles = StyleSheet.create({
},
postText: {
fontFamily: 'Helvetica Neue',
fontSize: 17,
lineHeight: 22.1, // 1.3 of 17px
fontSize: 16,
lineHeight: 20.8, // 1.3 of 16px
},
})

View File

@ -1,12 +1,6 @@
import React, {useMemo} from 'react'
import {observer} from 'mobx-react-lite'
import {
ActivityIndicator,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native'
import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AtUri} from '../../../third-party/uri'
@ -20,9 +14,8 @@ import {
import {pluralize} from '../../../lib/strings'
import {s, colors} from '../../lib/styles'
import {getGradient} from '../../lib/asset-gen'
import {MagnifyingGlassIcon} from '../../lib/icons'
import {DropdownBtn, DropdownItem} from '../util/DropdownBtn'
import Toast from '../util/Toast'
import * as Toast from '../util/Toast'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
import {RichText} from '../util/RichText'
import {UserAvatar} from '../util/UserAvatar'
@ -55,10 +48,6 @@ export const ProfileHeader = observer(function ProfileHeader({
`${view.myState.follow ? 'Following' : 'No longer following'} ${
view.displayName || view.handle
}`,
{
duration: Toast.durations.LONG,
position: Toast.positions.TOP,
},
)
},
err => console.error('Failed to toggle follow', err),
@ -94,10 +83,7 @@ export const ProfileHeader = observer(function ProfileHeader({
did: store.me.did || '',
rkey: new AtUri(view.myState.member).rkey,
})
Toast.show(`Scene left`, {
duration: Toast.durations.LONG,
position: Toast.positions.TOP,
})
Toast.show(`Scene left`)
}
onRefreshAll()
}
@ -108,18 +94,6 @@ export const ProfileHeader = observer(function ProfileHeader({
return (
<View style={styles.outer}>
<LoadingPlaceholder width="100%" height={120} />
{store.nav.tab.canGoBack ? (
<TouchableOpacity style={styles.backButton} onPress={onPressBack}>
<FontAwesomeIcon
size={18}
icon="angle-left"
style={styles.backIcon}
/>
</TouchableOpacity>
) : undefined}
<TouchableOpacity style={styles.searchBtn} onPress={onPressSearch}>
<MagnifyingGlassIcon size={19} style={styles.searchIcon} />
</TouchableOpacity>
<View style={styles.avi}>
<LoadingPlaceholder
width={80}
@ -179,18 +153,6 @@ export const ProfileHeader = observer(function ProfileHeader({
return (
<View style={styles.outer}>
<UserBanner handle={view.handle} />
{store.nav.tab.canGoBack ? (
<TouchableOpacity style={styles.backButton} onPress={onPressBack}>
<FontAwesomeIcon
size={18}
icon="angle-left"
style={styles.backIcon}
/>
</TouchableOpacity>
) : undefined}
<TouchableOpacity style={styles.searchBtn} onPress={onPressSearch}>
<MagnifyingGlassIcon size={19} style={styles.searchIcon} />
</TouchableOpacity>
<View style={styles.avi}>
<UserAvatar
size={80}
@ -353,30 +315,6 @@ const styles = StyleSheet.create({
width: '100%',
height: 120,
},
backButton: {
position: 'absolute',
top: 10,
left: 12,
backgroundColor: '#ffff',
padding: 6,
borderRadius: 30,
},
backIcon: {
width: 14,
height: 14,
color: colors.black,
},
searchBtn: {
position: 'absolute',
top: 10,
right: 12,
backgroundColor: '#ffff',
padding: 5,
borderRadius: 30,
},
searchIcon: {
color: colors.black,
},
avi: {
position: 'absolute',
top: 80,

View File

@ -12,9 +12,10 @@ import {UpIcon, UpIconSolid} from '../../lib/icons'
import {s, colors} from '../../lib/styles'
interface PostCtrlsOpts {
replyCount: number
repostCount: number
upvoteCount: number
big?: boolean
replyCount?: number
repostCount?: number
upvoteCount?: number
isReposted: boolean
isUpvoted: boolean
onPressReply: () => void
@ -30,17 +31,17 @@ export function PostCtrls(opts: PostCtrlsOpts) {
const interp2 = useSharedValue<number>(0)
const anim1Style = useAnimatedStyle(() => ({
transform: [{scale: interpolate(interp1.value, [0, 1.0], [1.0, 3.0])}],
transform: [{scale: interpolate(interp1.value, [0, 1.0], [1.0, 4.0])}],
opacity: interpolate(interp1.value, [0, 1.0], [1.0, 0.0]),
}))
const anim2Style = useAnimatedStyle(() => ({
transform: [{scale: interpolate(interp2.value, [0, 1.0], [1.0, 3.0])}],
transform: [{scale: interpolate(interp2.value, [0, 1.0], [1.0, 4.0])}],
opacity: interpolate(interp2.value, [0, 1.0], [1.0, 0.0]),
}))
const onPressToggleRepostWrapper = () => {
if (!opts.isReposted) {
interp1.value = withTiming(1, {duration: 300}, () => {
interp1.value = withTiming(1, {duration: 400}, () => {
interp1.value = withDelay(100, withTiming(0, {duration: 20}))
})
}
@ -48,7 +49,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
}
const onPressToggleUpvoteWrapper = () => {
if (!opts.isUpvoted) {
interp2.value = withTiming(1, {duration: 300}, () => {
interp2.value = withTiming(1, {duration: 400}, () => {
interp2.value = withDelay(100, withTiming(0, {duration: 20}))
})
}
@ -62,9 +63,11 @@ export function PostCtrls(opts: PostCtrlsOpts) {
<FontAwesomeIcon
style={styles.ctrlIcon}
icon={['far', 'comment']}
size={14}
size={opts.big ? 20 : 14}
/>
{typeof opts.replyCount !== 'undefined' ? (
<Text style={[sRedgray, s.ml5, s.f16]}>{opts.replyCount}</Text>
) : undefined}
</TouchableOpacity>
</View>
<View style={s.flex1}>
@ -77,9 +80,10 @@ export function PostCtrls(opts: PostCtrlsOpts) {
opts.isReposted ? styles.ctrlIconReposted : styles.ctrlIcon
}
icon="retweet"
size={18}
size={opts.big ? 22 : 18}
/>
</Animated.View>
{typeof opts.repostCount !== 'undefined' ? (
<Text
style={
opts.isReposted
@ -88,6 +92,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
}>
{opts.repostCount}
</Text>
) : undefined}
</TouchableOpacity>
</View>
<View style={s.flex1}>
@ -96,11 +101,19 @@ export function PostCtrls(opts: PostCtrlsOpts) {
onPress={onPressToggleUpvoteWrapper}>
<Animated.View style={anim2Style}>
{opts.isUpvoted ? (
<UpIconSolid style={[styles.ctrlIconUpvoted]} size={18} />
<UpIconSolid
style={[styles.ctrlIconUpvoted]}
size={opts.big ? 22 : 18}
/>
) : (
<UpIcon style={[styles.ctrlIcon]} size={18} strokeWidth={1.5} />
<UpIcon
style={[styles.ctrlIcon]}
size={opts.big ? 22 : 18}
strokeWidth={1.5}
/>
)}
</Animated.View>
{typeof opts.upvoteCount !== 'undefined' ? (
<Text
style={
opts.isUpvoted
@ -109,6 +122,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
}>
{opts.upvoteCount}
</Text>
) : undefined}
</TouchableOpacity>
</View>
<View style={s.flex1}></View>

View File

@ -1,2 +0,0 @@
import Toast from 'react-native-root-toast'
export default Toast

View File

@ -1,62 +1,11 @@
/*
* Note: the dataSet properties are used to leverage custom CSS in public/index.html
*/
import Toast from 'react-native-root-toast'
import React, {useState, useEffect} from 'react'
// @ts-ignore no declarations available -prf
import {Text, View} from 'react-native-web'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
interface ActiveToast {
text: string
}
type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void
// globals
// =
let globalSetActiveToast: GlobalSetActiveToast | undefined
let toastTimeout: NodeJS.Timeout | undefined
// components
// =
type ToastContainerProps = {}
const ToastContainer: React.FC<ToastContainerProps> = ({}) => {
const [activeToast, setActiveToast] = useState<ActiveToast | undefined>()
useEffect(() => {
globalSetActiveToast = (t: ActiveToast | undefined) => {
setActiveToast(t)
}
export function show(message: string) {
Toast.show(message, {
duration: Toast.durations.LONG,
position: 50,
shadow: true,
animation: true,
hideOnPress: true,
})
return (
<>
{activeToast && (
<View dataSet={{'toast-container': 1}}>
<FontAwesomeIcon icon="check" size={24} />
<Text>{activeToast.text}</Text>
</View>
)}
</>
)
}
// exports
// =
export default {
show(text: string, _opts: any) {
console.log('TODO: toast', text)
if (toastTimeout) {
clearTimeout(toastTimeout)
}
globalSetActiveToast?.({text})
toastTimeout = setTimeout(() => {
globalSetActiveToast?.(undefined)
}, 2e3)
},
positions: {
TOP: 0,
},
durations: {
LONG: 0,
},
ToastContainer,
}

View File

@ -1,7 +1,6 @@
import React from 'react'
import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {UserAvatar} from './UserAvatar'
import {colors} from '../../lib/styles'
import {MagnifyingGlassIcon} from '../../lib/icons'
import {useStores} from '../../../state'
@ -9,14 +8,19 @@ import {useStores} from '../../../state'
export function ViewHeader({
title,
subtitle,
onPost,
}: {
title: string
subtitle?: string
onPost?: () => void
}) {
const store = useStores()
const onPressBack = () => {
store.nav.tab.goBack()
}
const onPressCompose = () => {
store.shell.openComposer({onPost})
}
const onPressSearch = () => {
store.nav.navigate(`/search`)
}
@ -26,9 +30,7 @@ export function ViewHeader({
<TouchableOpacity onPress={onPressBack} style={styles.backIcon}>
<FontAwesomeIcon size={18} icon="angle-left" style={{marginTop: 6}} />
</TouchableOpacity>
) : (
<View style={styles.cornerPlaceholder} />
)}
) : undefined}
<View style={styles.titleContainer}>
<Text style={styles.title}>{title}</Text>
{subtitle ? (
@ -37,8 +39,17 @@ export function ViewHeader({
</Text>
) : undefined}
</View>
<TouchableOpacity onPress={onPressSearch} style={styles.searchBtn}>
<MagnifyingGlassIcon size={17} style={styles.searchBtnIcon} />
<TouchableOpacity onPress={onPressCompose} style={styles.btn}>
<FontAwesomeIcon size={18} icon="plus" />
</TouchableOpacity>
<TouchableOpacity
onPress={onPressSearch}
style={[styles.btn, {marginLeft: 8}]}>
<MagnifyingGlassIcon
size={18}
strokeWidth={3}
style={styles.searchBtnIcon}
/>
</TouchableOpacity>
</View>
)
@ -59,33 +70,28 @@ const styles = StyleSheet.create({
titleContainer: {
flexDirection: 'row',
alignItems: 'baseline',
marginLeft: 'auto',
marginRight: 'auto',
},
title: {
fontSize: 16,
fontSize: 21,
fontWeight: '600',
},
subtitle: {
fontSize: 15,
marginLeft: 3,
fontSize: 18,
marginLeft: 6,
color: colors.gray4,
maxWidth: 200,
},
cornerPlaceholder: {
width: 30,
height: 30,
},
backIcon: {width: 30, height: 30},
searchBtn: {
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.gray1,
width: 30,
height: 30,
borderRadius: 15,
width: 36,
height: 36,
borderRadius: 20,
},
searchBtnIcon: {
color: colors.black,

View File

@ -94,15 +94,17 @@ export function HomeIconSolid({
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={2}
strokeWidth={strokeWidth}
stroke="currentColor"
width={size || 24}
height={size || 24}

View File

@ -47,6 +47,7 @@ export const Home = observer(function Home({
if (!visible) {
return
}
if (hasSetup) {
console.log('Updating home feed')
defaultFeedView.update()
@ -80,7 +81,11 @@ export const Home = observer(function Home({
return (
<View style={s.flex1}>
<ViewHeader title="Bluesky" subtitle="Private Beta" />
<ViewHeader
title="Bluesky"
subtitle="Private Beta"
onPost={onCreatePost}
/>
<Feed
key="default"
feed={defaultFeedView}
@ -106,8 +111,8 @@ const styles = StyleSheet.create({
left: 10,
bottom: 15,
backgroundColor: colors.pink3,
paddingHorizontal: 10,
paddingVertical: 8,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 30,
shadowColor: '#000',
shadowOpacity: 0.3,
@ -117,5 +122,6 @@ const styles = StyleSheet.create({
color: colors.white,
fontWeight: 'bold',
marginLeft: 5,
fontSize: 16,
},
})

View File

@ -15,7 +15,8 @@ import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder'
import {ErrorScreen} from '../com/util/ErrorScreen'
import {ErrorMessage} from '../com/util/ErrorMessage'
import {EmptyState} from '../com/util/EmptyState'
import Toast from '../com/util/Toast'
import {ViewHeader} from '../com/util/ViewHeader'
import * as Toast from '../com/util/Toast'
import {s, colors} from '../lib/styles'
const LOADING_ITEM = {_reactKey: '__loading__'}
@ -77,10 +78,7 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
`You'll be able to invite them again if you change your mind.`,
async () => {
await uiState.members.removeMember(membership.did)
Toast.show(`User removed`, {
duration: Toast.durations.LONG,
position: Toast.positions.TOP,
})
Toast.show(`User removed`)
},
),
)
@ -219,8 +217,11 @@ export const Profile = observer(({navIdx, visible, params}: ScreenParams) => {
renderItem = () => <View />
}
const title =
uiState.profile.displayName || uiState.profile.handle || params.name
return (
<View style={styles.container}>
<ViewHeader title={title} />
{uiState.profile.hasError ? (
<ErrorScreen
title="Failed to load profile"

View File

@ -8,7 +8,6 @@ import {
TouchableWithoutFeedback,
View,
} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import Animated, {
useSharedValue,
useAnimatedStyle,
@ -25,10 +24,17 @@ import {CreateSceneModel} from '../../../state/models/shell-ui'
import {s, colors} from '../../lib/styles'
export const MainMenu = observer(
({active, onClose}: {active: boolean; onClose: () => void}) => {
({
active,
insetBottom,
onClose,
}: {
active: boolean
insetBottom: number
onClose: () => void
}) => {
const store = useStores()
const initInterp = useSharedValue<number>(0)
const insets = useSafeAreaInsets()
useEffect(() => {
if (active) {
@ -172,7 +178,7 @@ export const MainMenu = observer(
<Animated.View
style={[
styles.wrapper,
{bottom: insets.bottom + 55},
{bottom: insetBottom + 45},
wrapperAnimStyle,
]}>
<SafeAreaView>
@ -267,7 +273,8 @@ const styles = StyleSheet.create({
alignItems: 'center',
height: 40,
paddingHorizontal: 10,
marginBottom: 16,
marginTop: 12,
marginBottom: 20,
},
section: {
paddingHorizontal: 10,

View File

@ -70,7 +70,7 @@ const Btn = ({
onPress?: (event: GestureResponderEvent) => void
onLongPress?: (event: GestureResponderEvent) => void
}) => {
let size = 21
let size = 24
let addedStyles
let IconEl
if (icon === 'menu') {
@ -79,17 +79,17 @@ const Btn = ({
IconEl = GridIconSolid
} else if (icon === 'home') {
IconEl = HomeIcon
size = 24
size = 27
} else if (icon === 'home-solid') {
IconEl = HomeIconSolid
size = 24
size = 27
} else if (icon === 'bell') {
IconEl = BellIcon
size = 24
size = 27
addedStyles = {position: 'relative', top: -1} as ViewStyle
} else if (icon === 'bell-solid') {
IconEl = BellIconSolid
size = 24
size = 27
addedStyles = {position: 'relative', top: -1} as ViewStyle
} else {
IconEl = FontAwesomeIcon
@ -316,7 +316,7 @@ export const MobileShell: React.FC = observer(() => {
<View
style={[
styles.bottomBar,
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)},
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 40)},
]}>
<Btn
icon={isAtHome ? 'home-solid' : 'home'}
@ -343,6 +343,7 @@ export const MobileShell: React.FC = observer(() => {
</View>
<MainMenu
active={isMainMenuActive}
insetBottom={clamp(safeAreaInsets.bottom, 15, 40)}
onClose={() => setMainMenuActive(false)}
/>
<Modal />
@ -491,7 +492,7 @@ const styles = StyleSheet.create({
},
ctrl: {
flex: 1,
paddingTop: 15,
paddingTop: 12,
paddingBottom: 5,
},
notificationCount: {

View File

@ -11718,6 +11718,11 @@ thunky@^1.0.2:
resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d"
integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==
tlds@^1.234.0:
version "1.234.0"
resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.234.0.tgz#f61fe73f6e85c51f8503181f47dcfbd18c6910db"
integrity sha512-TNDfeyDIC+oroH44bMbWC+Jn/2qNrfRvDK2EXt1icOXYG5NMqoRyUosADrukfb4D8lJ3S1waaBWSvQro0erdng==
tmpl@1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc"