diff --git a/package.json b/package.json index 66d300d3..3e4b16d7 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "e2e": "detox test --configuration ios.sim.debug --take-screenshots all" }, "dependencies": { - "@atproto/api": "^0.1.2", + "@atproto/api": "0.1.3", "@atproto/lexicon": "^0.0.4", "@atproto/xrpc": "^0.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4", diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index ae156928..3b8af44e 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -2,6 +2,7 @@ import { AppBskyEmbedImages, AppBskyEmbedExternal, ComAtprotoBlobUpload, + AppBskyEmbedRecord, } from '@atproto/api' import {AtUri} from '../../third-party/uri' import {RootStoreModel} from 'state/models/root-store' @@ -51,23 +52,32 @@ export async function uploadBlob( } } -export async function post( - store: RootStoreModel, - rawText: string, - replyTo?: string, - extLink?: ExternalEmbedDraft, - images?: string[], - knownHandles?: Set, - onStateChange?: (state: string) => void, -) { - let embed: AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main | undefined +interface PostOpts { + rawText: string + replyTo?: string + quote?: { + uri: string + cid: string + } + extLink?: ExternalEmbedDraft + images?: string[] + knownHandles?: Set + onStateChange?: (state: string) => void +} + +export async function post(store: RootStoreModel, opts: PostOpts) { + let embed: + | AppBskyEmbedImages.Main + | AppBskyEmbedExternal.Main + | AppBskyEmbedRecord.Main + | undefined let reply - const text = new RichText(rawText, undefined, { + const text = new RichText(opts.rawText, undefined, { cleanNewlines: true, }).text.trim() - onStateChange?.('Processing...') - const entities = extractEntities(text, knownHandles) + opts.onStateChange?.('Processing...') + const entities = extractEntities(text, opts.knownHandles) if (entities) { for (const ent of entities) { if (ent.type === 'mention') { @@ -77,14 +87,22 @@ export async function post( } } - if (images?.length) { + if (opts.quote) { + embed = { + $type: 'app.bsky.embed.record', + record: { + uri: opts.quote.uri, + cid: opts.quote.cid, + }, + } as AppBskyEmbedRecord.Main + } else if (opts.images?.length) { embed = { $type: 'app.bsky.embed.images', images: [], } as AppBskyEmbedImages.Main let i = 1 - for (const image of images) { - onStateChange?.(`Uploading image #${i++}...`) + for (const image of opts.images) { + opts.onStateChange?.(`Uploading image #${i++}...`) const res = await uploadBlob(store, image, 'image/jpeg') embed.images.push({ image: { @@ -94,30 +112,28 @@ export async function post( alt: '', // TODO supply alt text }) } - } - - if (!embed && extLink) { + } else if (opts.extLink) { let thumb - if (extLink.localThumb) { - onStateChange?.('Uploading link thumbnail...') + if (opts.extLink.localThumb) { + opts.onStateChange?.('Uploading link thumbnail...') let encoding - if (extLink.localThumb.path.endsWith('.png')) { + if (opts.extLink.localThumb.path.endsWith('.png')) { encoding = 'image/png' } else if ( - extLink.localThumb.path.endsWith('.jpeg') || - extLink.localThumb.path.endsWith('.jpg') + opts.extLink.localThumb.path.endsWith('.jpeg') || + opts.extLink.localThumb.path.endsWith('.jpg') ) { encoding = 'image/jpeg' } else { store.log.warn( 'Unexpected image format for thumbnail, skipping', - extLink.localThumb.path, + opts.extLink.localThumb.path, ) } if (encoding) { const thumbUploadRes = await uploadBlob( store, - extLink.localThumb.path, + opts.extLink.localThumb.path, encoding, ) thumb = { @@ -129,16 +145,16 @@ export async function post( embed = { $type: 'app.bsky.embed.external', external: { - uri: extLink.uri, - title: extLink.meta?.title || '', - description: extLink.meta?.description || '', + uri: opts.extLink.uri, + title: opts.extLink.meta?.title || '', + description: opts.extLink.meta?.description || '', thumb, }, } as AppBskyEmbedExternal.Main } - if (replyTo) { - const replyToUrip = new AtUri(replyTo) + if (opts.replyTo) { + const replyToUrip = new AtUri(opts.replyTo) const parentPost = await store.api.app.bsky.feed.post.get({ user: replyToUrip.host, rkey: replyToUrip.rkey, @@ -156,7 +172,7 @@ export async function post( } try { - onStateChange?.('Posting...') + opts.onStateChange?.('Posting...') return await store.api.app.bsky.feed.post.create( {did: store.me.did || ''}, { diff --git a/src/state/models/shell-ui.ts b/src/state/models/shell-ui.ts index 640bed0b..1b0e350a 100644 --- a/src/state/models/shell-ui.ts +++ b/src/state/models/shell-ui.ts @@ -44,6 +44,13 @@ export interface DeleteAccountModal { name: 'delete-account' } +export interface RepostModal { + name: 'repost' + onRepost: () => void + onQuote: () => void + isReposted: boolean +} + export type Modal = | ConfirmModal | EditProfileModal @@ -52,6 +59,7 @@ export type Modal = | ReportAccountModal | CropImageModal | DeleteAccountModal + | RepostModal interface LightboxModel {} @@ -82,10 +90,22 @@ export interface ComposerOptsPostRef { avatar?: string } } +export interface ComposerOptsQuote { + uri: string + cid: string + text: string + indexedAt: string + author: { + handle: string + displayName?: string + avatar?: string + } +} export interface ComposerOpts { imagesOpen?: boolean replyTo?: ComposerOptsPostRef onPost?: () => void + quote?: ComposerOptsQuote } export class ShellUiModel { diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx index e3befaff..ad6a8ec6 100644 --- a/src/view/com/composer/ComposePost.tsx +++ b/src/view/com/composer/ComposePost.tsx @@ -48,6 +48,7 @@ import { POST_IMG_MAX_SIZE, } from 'lib/constants' import {isWeb} from 'platform/detection' +import QuoteEmbed from '../util/PostEmbeds/QuoteEmbed' const MAX_TEXT_LENGTH = 256 const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} @@ -62,11 +63,13 @@ export const ComposePost = observer(function ComposePost({ imagesOpen, onPost, onClose, + quote, }: { replyTo?: ComposerOpts['replyTo'] imagesOpen?: ComposerOpts['imagesOpen'] onPost?: ComposerOpts['onPost'] onClose: () => void + quote?: ComposerOpts['quote'] }) { const {track} = useAnalytics() const pal = usePalette('default') @@ -280,15 +283,15 @@ export const ComposePost = observer(function ComposePost({ } setIsProcessing(true) try { - await apilib.post( - store, - text, - replyTo?.uri, - extLink, - selectedPhotos, - autocompleteView.knownHandles, - setProcessingState, - ) + await apilib.post(store, { + rawText: text, + replyTo: replyTo?.uri, + images: selectedPhotos, + quote: quote, + extLink: extLink, + onStateChange: setProcessingState, + knownHandles: autocompleteView.knownHandles, + }) track('Create Post', { imageCount: selectedPhotos.length, }) @@ -418,6 +421,7 @@ export const ComposePost = observer(function ComposePost({ ) : undefined} + + + {quote ? ( + + + + ) : undefined} + ) : !extLink && selectedPhotos.length === 0 && - suggestedExtLinks.size > 0 ? ( + suggestedExtLinks.size > 0 && + !quote ? ( {Array.from(suggestedExtLinks).map(url => ( ) : null} - - - + {quote ? undefined : ( + + + + )} diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 2b4457c4..f939442b 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -9,6 +9,7 @@ import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' import * as ServerInputModal from './ServerInput' import * as ReportPostModal from './ReportPost' +import * as RepostModal from './Repost' import * as ReportAccountModal from './ReportAccount' import * as DeleteAccountModal from './DeleteAccount' import {usePalette} from 'lib/hooks/usePalette' @@ -61,6 +62,9 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'delete-account') { snapPoints = DeleteAccountModal.snapPoints element = + } else if (activeModal?.name === 'repost') { + snapPoints = RepostModal.snapPoints + element = } else { element = } diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 38b526d2..b10b60be 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -10,6 +10,7 @@ import * as EditProfileModal from './EditProfile' import * as ServerInputModal from './ServerInput' import * as ReportPostModal from './ReportPost' import * as ReportAccountModal from './ReportAccount' +import * as RepostModal from './Repost' import * as CropImageModal from './crop-image/CropImage.web' export const ModalsContainer = observer(function ModalsContainer() { @@ -59,6 +60,8 @@ function Modal({modal}: {modal: ModalIface}) { element = } else if (modal.name === 'crop-image') { element = + } else if (modal.name === 'repost') { + element = } else { return null } diff --git a/src/view/com/modals/Repost.tsx b/src/view/com/modals/Repost.tsx new file mode 100644 index 00000000..6ab15317 --- /dev/null +++ b/src/view/com/modals/Repost.tsx @@ -0,0 +1,90 @@ +import React from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import LinearGradient from 'react-native-linear-gradient' +import {useStores} from 'state/index' +import {s, colors, gradients} from 'lib/styles' +import {Text} from '../util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {RepostIcon} from 'lib/icons' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' + +export const snapPoints = [250] + +export function Component({ + onRepost, + onQuote, + isReposted, +}: { + onRepost: () => void + onQuote: () => void + isReposted: boolean +}) { + const store = useStores() + const pal = usePalette('default') + const onPress = async () => { + store.shell.closeModal() + } + + return ( + + + + + + {!isReposted ? 'Repost' : 'Undo repost'} + + + + + + Quote Post + + + + + + Cancel + + + + ) +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 30, + }, + title: { + textAlign: 'center', + fontWeight: 'bold', + fontSize: 24, + marginBottom: 12, + }, + description: { + textAlign: 'center', + fontSize: 17, + paddingHorizontal: 22, + marginBottom: 10, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + borderRadius: 32, + padding: 14, + backgroundColor: colors.gray1, + }, + actionBtn: { + flexDirection: 'row', + alignItems: 'center', + }, + actionBtnLabel: { + paddingHorizontal: 14, + paddingVertical: 16, + }, +}) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 65bae019..a95d9179 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -245,6 +245,13 @@ export const PostThreadItem = observer(function PostThreadItem({ itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} + author={{ + avatar: item.post.author.avatar!, + handle: item.post.author.handle, + displayName: item.post.author.displayName!, + }} + text={item.richText?.text || record.text} + indexedAt={item.post.indexedAt} isAuthor={item.post.author.did === store.me.did} isReposted={!!item.post.viewer.repost} isUpvoted={!!item.post.viewer.upvote} @@ -329,6 +336,13 @@ export const PostThreadItem = observer(function PostThreadItem({ itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} + author={{ + avatar: item.post.author.avatar!, + handle: item.post.author.handle, + displayName: item.post.author.displayName!, + }} + text={item.richText?.text || record.text} + indexedAt={item.post.indexedAt} isAuthor={item.post.author.did === store.me.did} replyCount={item.post.replyCount} repostCount={item.post.repostCount} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index c0ff9541..e8e6781f 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -197,6 +197,13 @@ export const Post = observer(function Post({ itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} + author={{ + avatar: item.post.author.avatar!, + handle: item.post.author.handle, + displayName: item.post.author.displayName!, + }} + indexedAt={item.post.indexedAt} + text={item.richText?.text || record.text} isAuthor={item.post.author.did === store.me.did} replyCount={item.post.replyCount} repostCount={item.post.repostCount} diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index c3e9f61f..1847827c 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -226,6 +226,13 @@ export const FeedItem = observer(function ({ itemCid={itemCid} itemHref={itemHref} itemTitle={itemTitle} + author={{ + avatar: item.post.author.avatar!, + handle: item.post.author.handle, + displayName: item.post.author.displayName!, + }} + text={item.richText?.text || record.text} + indexedAt={item.post.indexedAt} isAuthor={item.post.author.did === store.me.did} replyCount={item.post.replyCount} repostCount={item.post.repostCount} diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/PostCtrls.tsx index e42c5e63..cb4dfab2 100644 --- a/src/view/com/util/PostCtrls.tsx +++ b/src/view/com/util/PostCtrls.tsx @@ -26,6 +26,7 @@ import { } from 'lib/icons' import {s, colors} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' +import {useStores} from 'state/index' interface PostCtrlsOpts { itemUri: string @@ -33,6 +34,13 @@ interface PostCtrlsOpts { itemHref: string itemTitle: string isAuthor: boolean + author: { + handle: string + displayName: string + avatar: string + } + text: string + indexedAt: string big?: boolean style?: StyleProp replyCount?: number @@ -86,6 +94,7 @@ function ctrlAnimStyle(interp: Animated.Value) { */ export function PostCtrls(opts: PostCtrlsOpts) { + const store = useStores() const theme = useTheme() const defaultCtrlColor = React.useMemo( () => ({ @@ -98,7 +107,8 @@ export function PostCtrls(opts: PostCtrlsOpts) { // DISABLED see #135 // const repostRef = React.useRef(null) // const likeRef = React.useRef(null) - const onPressToggleRepostWrapper = () => { + const onRepost = () => { + store.shell.closeModal() if (!opts.isReposted) { ReactNativeHapticFeedback.trigger('impactMedium') setRepostMod(1) @@ -122,6 +132,30 @@ export function PostCtrls(opts: PostCtrlsOpts) { .then(() => setRepostMod(0)) } } + + const onQuote = () => { + store.shell.closeModal() + store.shell.openComposer({ + quote: { + uri: opts.itemUri, + cid: opts.itemCid, + text: opts.text, + author: opts.author, + indexedAt: opts.indexedAt, + }, + }) + ReactNativeHapticFeedback.trigger('impactMedium') + } + + const onPressToggleRepostWrapper = () => { + store.shell.openModal({ + name: 'repost', + onRepost: onRepost, + onQuote: onQuote, + isReposted: opts.isReposted, + }) + } + const onPressToggleUpvoteWrapper = () => { if (!opts.isUpvoted) { ReactNativeHapticFeedback.trigger('impactMedium') diff --git a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx b/src/view/com/util/PostEmbeds/QuoteEmbed.tsx new file mode 100644 index 00000000..76b71a53 --- /dev/null +++ b/src/view/com/util/PostEmbeds/QuoteEmbed.tsx @@ -0,0 +1,58 @@ +import {StyleSheet} from 'react-native' +import React from 'react' +import {AtUri} from '../../../../third-party/uri' +import {PostMeta} from '../PostMeta' +import {Link} from '../Link' +import {Text} from '../text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {ComposerOptsQuote} from 'state/models/shell-ui' + +const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => { + const pal = usePalette('default') + const itemUrip = new AtUri(quote.uri) + const itemHref = `/profile/${quote.author.handle}/post/${itemUrip.rkey}` + const itemTitle = `Post by ${quote.author.handle}` + const isEmpty = React.useMemo( + () => quote.text.trim().length === 0, + [quote.text], + ) + return ( + + + + {isEmpty ? ( + + View post + + ) : ( + quote.text + )} + + + ) +} + +export default QuoteEmbed + +const styles = StyleSheet.create({ + container: { + borderRadius: 8, + paddingVertical: 8, + paddingHorizontal: 12, + marginVertical: 8, + borderWidth: 1, + }, + quotePost: { + flex: 1, + paddingLeft: 13, + paddingRight: 8, + }, +}) diff --git a/src/view/com/util/PostEmbeds/index.tsx b/src/view/com/util/PostEmbeds/index.tsx index d2186b60..3d335671 100644 --- a/src/view/com/util/PostEmbeds/index.tsx +++ b/src/view/com/util/PostEmbeds/index.tsx @@ -6,7 +6,12 @@ import { ViewStyle, Image as RNImage, } from 'react-native' -import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api' +import { + AppBskyEmbedImages, + AppBskyEmbedExternal, + AppBskyEmbedRecord, + AppBskyFeedPost, +} from '@atproto/api' import {Link} from '../Link' import {AutoSizedImage} from '../images/AutoSizedImage' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' @@ -17,8 +22,10 @@ import {saveImageModal} from 'lib/media/manip' import YoutubeEmbed from './YoutubeEmbed' import ExternalLinkEmbed from './ExternalLinkEmbed' import {getYoutubeVideoId} from 'lib/strings/url-helpers' +import QuoteEmbed from './QuoteEmbed' type Embed = + | AppBskyEmbedRecord.Presented | AppBskyEmbedImages.Presented | AppBskyEmbedExternal.Presented | {$type: string; [k: string]: unknown} @@ -32,6 +39,25 @@ export function PostEmbeds({ }) { const pal = usePalette('default') const store = useStores() + if (AppBskyEmbedRecord.isPresented(embed)) { + if ( + AppBskyEmbedRecord.isPresentedRecord(embed.record) && + AppBskyFeedPost.isRecord(embed.record.record) && + AppBskyFeedPost.validateRecord(embed.record.record).success + ) { + return ( + + ) + } + } if (AppBskyEmbedImages.isPresented(embed)) { if (embed.images.length > 0) { const uris = embed.images.map(img => img.fullsize) diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index a07d9189..0c5d41ca 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -4,15 +4,17 @@ import {Text} from './text/Text' import {ago} from 'lib/strings/time' import {usePalette} from 'lib/hooks/usePalette' import {useStores} from 'state/index' +import {UserAvatar} from './UserAvatar' import {observer} from 'mobx-react-lite' import FollowButton from '../profile/FollowButton' interface PostMetaOpts { + authorAvatar: string | undefined authorHandle: string authorDisplayName: string | undefined timestamp: string - did: string - declarationCid: string + did?: string + declarationCid?: string showFollowBtn?: boolean } @@ -27,11 +29,18 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { // don't change this UI immediately, but rather upon future // renders const isFollowing = React.useMemo( - () => store.me.follows.isFollowing(opts.did), + () => + typeof opts.did === 'string' && store.me.follows.isFollowing(opts.did), [opts.did, store.me.follows], ) - if (opts.showFollowBtn && !isMe && !isFollowing) { + if ( + opts.showFollowBtn && + !isMe && + !isFollowing && + opts.did && + opts.declarationCid + ) { // two-liner with follow button return ( @@ -71,6 +80,16 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { // one-liner return ( + {typeof opts.authorAvatar !== 'undefined' && ( + + + + )} void + quote?: ComposerOpts['quote'] }) => { const pal = usePalette('default') const initInterp = useAnimatedValue(0) @@ -62,6 +64,7 @@ export const Composer = observer( imagesOpen={imagesOpen} onPost={onPost} onClose={onClose} + quote={quote} /> ) diff --git a/src/view/shell/mobile/index.tsx b/src/view/shell/mobile/index.tsx index 80403a6d..89a834ee 100644 --- a/src/view/shell/mobile/index.tsx +++ b/src/view/shell/mobile/index.tsx @@ -550,6 +550,7 @@ export const MobileShell: React.FC = observer(() => { replyTo={store.shell.composerOpts?.replyTo} imagesOpen={store.shell.composerOpts?.imagesOpen} onPost={store.shell.composerOpts?.onPost} + quote={store.shell.composerOpts?.quote} /> ) diff --git a/yarn.lock b/yarn.lock index 4886fac7..6583e0a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19,10 +19,10 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@atproto/api@^0.1.2": - version "0.1.2" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.1.2.tgz#66102f9203ba499432bc5aeb30cd19313ab2e4fc" - integrity sha512-lDcFGkrk0J7rkIPSie18xS7sO3IL6DsosX8GgoeqCNeVaDuphBRaFCcpBUWf0q4fHrpmdgghGo4ulefyKHTIFQ== +"@atproto/api@0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.1.3.tgz#4aa9ea7caad624a7eda7d22e03f076e4b0fb68fb" + integrity sha512-jEtE0Afxnkvth7/dZKYx9Gv1IpO2Jlmb8KzgRVPnyYyolI2GI4VTNs7mxxO/44cs8vKu2PN2zW+64XuaIY1JBA== dependencies: "@atproto/xrpc" "*" typed-emitter "^2.1.0"