Merge branch 'main' into localization-german
This commit is contained in:
+140
-27
@@ -1,6 +1,5 @@
|
||||
import {RichText} from '@atproto/api'
|
||||
import {
|
||||
getYoutubeVideoId,
|
||||
makeRecordUri,
|
||||
toNiceDomain,
|
||||
toShortUrl,
|
||||
@@ -12,6 +11,7 @@ import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
|
||||
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
|
||||
import {makeValidHandle, createFullHandle} from '../../src/lib/strings/handles'
|
||||
import {cleanError} from '../../src/lib/strings/errors'
|
||||
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
|
||||
|
||||
describe('detectLinkables', () => {
|
||||
const inputs = [
|
||||
@@ -335,32 +335,6 @@ describe('toShareUrl', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getYoutubeVideoId', () => {
|
||||
it(' should return undefined for invalid youtube links', () => {
|
||||
expect(getYoutubeVideoId('')).toBeUndefined()
|
||||
expect(getYoutubeVideoId('https://www.google.com')).toBeUndefined()
|
||||
expect(getYoutubeVideoId('https://www.youtube.com')).toBeUndefined()
|
||||
expect(
|
||||
getYoutubeVideoId('https://www.youtube.com/channelName'),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
getYoutubeVideoId('https://www.youtube.com/channel/channelName'),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getYoutubeVideoId should return video id for valid youtube links', () => {
|
||||
expect(getYoutubeVideoId('https://www.youtube.com/watch?v=videoId')).toBe(
|
||||
'videoId',
|
||||
)
|
||||
expect(
|
||||
getYoutubeVideoId(
|
||||
'https://www.youtube.com/watch?v=videoId&feature=share',
|
||||
),
|
||||
).toBe('videoId')
|
||||
expect(getYoutubeVideoId('https://youtu.be/videoId')).toBe('videoId')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shortenLinks', () => {
|
||||
const inputs = [
|
||||
'start https://middle.com/foo/bar?baz=bux#hash end',
|
||||
@@ -396,6 +370,7 @@ describe('shortenLinks', () => {
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
it('correctly shortens rich text while preserving facet URIs', () => {
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
const input = inputs[i]
|
||||
@@ -410,3 +385,141 @@ describe('shortenLinks', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseEmbedPlayerFromUrl', () => {
|
||||
const inputs = [
|
||||
'https://youtu.be/videoId',
|
||||
'https://www.youtube.com/watch?v=videoId',
|
||||
'https://www.youtube.com/watch?v=videoId&feature=share',
|
||||
'https://youtube.com/watch?v=videoId',
|
||||
'https://youtube.com/watch?v=videoId&feature=share',
|
||||
'https://youtube.com/shorts/videoId',
|
||||
|
||||
'https://youtube.com/shorts/',
|
||||
'https://youtube.com/',
|
||||
'https://youtube.com/random',
|
||||
|
||||
'https://twitch.tv/channelName',
|
||||
'https://www.twitch.tv/channelName',
|
||||
|
||||
'https://open.spotify.com/playlist/playlistId',
|
||||
'https://open.spotify.com/playlist/playlistId?param=value',
|
||||
|
||||
'https://open.spotify.com/track/songId',
|
||||
'https://open.spotify.com/track/songId?param=value',
|
||||
|
||||
'https://open.spotify.com/album/albumId',
|
||||
'https://open.spotify.com/album/albumId?param=value',
|
||||
|
||||
'https://soundcloud.com/user/track',
|
||||
'https://soundcloud.com/user/sets/set',
|
||||
'https://soundcloud.com/user/',
|
||||
]
|
||||
|
||||
const outputs = [
|
||||
{
|
||||
type: 'youtube_video',
|
||||
videoId: 'videoId',
|
||||
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
|
||||
},
|
||||
{
|
||||
type: 'youtube_video',
|
||||
videoId: 'videoId',
|
||||
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
|
||||
},
|
||||
{
|
||||
type: 'youtube_video',
|
||||
videoId: 'videoId',
|
||||
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
|
||||
},
|
||||
{
|
||||
type: 'youtube_video',
|
||||
videoId: 'videoId',
|
||||
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
|
||||
},
|
||||
{
|
||||
type: 'youtube_video',
|
||||
videoId: 'videoId',
|
||||
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
|
||||
},
|
||||
{
|
||||
type: 'youtube_video',
|
||||
videoId: 'videoId',
|
||||
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
{
|
||||
type: 'twitch_live',
|
||||
channelId: 'channelName',
|
||||
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`,
|
||||
},
|
||||
{
|
||||
type: 'twitch_live',
|
||||
channelId: 'channelName',
|
||||
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`,
|
||||
},
|
||||
|
||||
{
|
||||
type: 'spotify_playlist',
|
||||
playlistId: 'playlistId',
|
||||
playerUri: `https://open.spotify.com/embed/playlist/playlistId`,
|
||||
},
|
||||
{
|
||||
type: 'spotify_playlist',
|
||||
playlistId: 'playlistId',
|
||||
playerUri: `https://open.spotify.com/embed/playlist/playlistId`,
|
||||
},
|
||||
|
||||
{
|
||||
type: 'spotify_song',
|
||||
songId: 'songId',
|
||||
playerUri: `https://open.spotify.com/embed/track/songId`,
|
||||
},
|
||||
{
|
||||
type: 'spotify_song',
|
||||
songId: 'songId',
|
||||
playerUri: `https://open.spotify.com/embed/track/songId`,
|
||||
},
|
||||
|
||||
{
|
||||
type: 'spotify_album',
|
||||
albumId: 'albumId',
|
||||
playerUri: `https://open.spotify.com/embed/album/albumId`,
|
||||
},
|
||||
{
|
||||
type: 'spotify_album',
|
||||
albumId: 'albumId',
|
||||
playerUri: `https://open.spotify.com/embed/album/albumId`,
|
||||
},
|
||||
|
||||
{
|
||||
type: 'soundcloud_track',
|
||||
user: 'user',
|
||||
track: 'track',
|
||||
playerUri: `https://w.soundcloud.com/player/?url=https://soundcloud.com/user/track&auto_play=true&visual=false&hide_related=true`,
|
||||
},
|
||||
{
|
||||
type: 'soundcloud_set',
|
||||
user: 'user',
|
||||
set: 'set',
|
||||
playerUri: `https://w.soundcloud.com/player/?url=https://soundcloud.com/user/sets/set&auto_play=true&visual=false&hide_related=true`,
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
|
||||
it('correctly grabs the correct id from uri', () => {
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
const input = inputs[i]
|
||||
const output = outputs[i]
|
||||
|
||||
const res = parseEmbedPlayerFromUrl(input)
|
||||
|
||||
console.log(input)
|
||||
|
||||
expect(res).toEqual(output)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
appbsky "github.com/bluesky-social/indigo/api/bsky"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
@@ -10,6 +12,12 @@ import (
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type ItemGUID struct {
|
||||
XMLName xml.Name `xml:"guid"`
|
||||
Value string `xml:",chardata"`
|
||||
IsPerma bool `xml:"isPermalink,attr"`
|
||||
}
|
||||
|
||||
// We don't actually populate the title for "posts".
|
||||
// Some background: https://book.micro.blog/rss-for-microblogs/
|
||||
type Item struct {
|
||||
@@ -17,8 +25,7 @@ type Item struct {
|
||||
Link string `xml:"link,omitempty"`
|
||||
Description string `xml:"description,omitempty"`
|
||||
PubDate string `xml:"pubDate,omitempty"`
|
||||
Author string `xml:"author,omitempty"`
|
||||
GUID string `xml:"guid,omitempty"`
|
||||
GUID ItemGUID
|
||||
}
|
||||
|
||||
type rss struct {
|
||||
@@ -71,12 +78,19 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
|
||||
if rec.Reply != nil {
|
||||
continue
|
||||
}
|
||||
pubDate := ""
|
||||
createdAt, err := syntax.ParseDatetimeLenient(rec.CreatedAt)
|
||||
if nil == err {
|
||||
pubDate = createdAt.Time().Format(time.RFC822Z)
|
||||
}
|
||||
posts = append(posts, Item{
|
||||
Link: fmt.Sprintf("https://bsky.app/profile/%s/post/%s", pv.Handle, aturi.RecordKey().String()),
|
||||
Description: rec.Text,
|
||||
PubDate: rec.CreatedAt,
|
||||
Author: "@" + pv.Handle,
|
||||
GUID: aturi.String(),
|
||||
PubDate: pubDate,
|
||||
GUID: ItemGUID{
|
||||
Value: aturi.String(),
|
||||
IsPerma: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import { Text } from "react-native";
|
||||
const text = "Hello World";
|
||||
<Text accessibilityLabel="Label is here">{text}</Text>
|
||||
```
|
||||
In this case, you cannot use the `useLingui()` hook:
|
||||
In this case, you can use the `useLingui()` hook:
|
||||
```jsx
|
||||
import { msg } from "@lingui/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
|
||||
+4
-1
@@ -137,7 +137,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-native": "0.72.5",
|
||||
"react-native-appstate-hook": "^1.0.6",
|
||||
"react-native-drawer-layout": "^3.2.0",
|
||||
"react-native-drawer-layout": "^4.0.0-alpha.3",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-gesture-handler": "^2.12.1",
|
||||
"react-native-get-random-values": "^1.8.0",
|
||||
@@ -160,6 +160,9 @@
|
||||
"react-native-version-number": "^0.3.6",
|
||||
"react-native-web": "~0.19.6",
|
||||
"react-native-web-linear-gradient": "^1.1.2",
|
||||
"react-native-web-webview": "^1.0.2",
|
||||
"react-native-webview": "^13.6.2",
|
||||
"react-native-youtube-iframe": "^2.3.0",
|
||||
"react-responsive": "^9.0.2",
|
||||
"rn-fetch-blob": "^0.12.0",
|
||||
"sentry-expo": "~7.0.1",
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
export type EmbedPlayerParams =
|
||||
| {type: 'youtube_video'; videoId: string; playerUri: string}
|
||||
| {type: 'twitch_live'; channelId: string; playerUri: string}
|
||||
| {type: 'spotify_album'; albumId: string; playerUri: string}
|
||||
| {
|
||||
type: 'spotify_playlist'
|
||||
playlistId: string
|
||||
playerUri: string
|
||||
}
|
||||
| {type: 'spotify_song'; songId: string; playerUri: string}
|
||||
| {type: 'soundcloud_track'; user: string; track: string; playerUri: string}
|
||||
| {type: 'soundcloud_set'; user: string; set: string; playerUri: string}
|
||||
|
||||
export function parseEmbedPlayerFromUrl(
|
||||
url: string,
|
||||
): EmbedPlayerParams | undefined {
|
||||
let urlp
|
||||
try {
|
||||
urlp = new URL(url)
|
||||
} catch (e) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// youtube
|
||||
if (urlp.hostname === 'youtu.be') {
|
||||
const videoId = urlp.pathname.split('/')[1]
|
||||
if (videoId) {
|
||||
return {
|
||||
type: 'youtube_video',
|
||||
videoId,
|
||||
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`,
|
||||
}
|
||||
}
|
||||
}
|
||||
if (urlp.hostname === 'www.youtube.com' || urlp.hostname === 'youtube.com') {
|
||||
const [_, page, shortVideoId] = urlp.pathname.split('/')
|
||||
const videoId =
|
||||
page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string)
|
||||
|
||||
if (videoId) {
|
||||
return {
|
||||
type: 'youtube_video',
|
||||
videoId,
|
||||
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// twitch
|
||||
if (urlp.hostname === 'twitch.tv' || urlp.hostname === 'www.twitch.tv') {
|
||||
const parts = urlp.pathname.split('/')
|
||||
if (parts.length === 2 && parts[1]) {
|
||||
return {
|
||||
type: 'twitch_live',
|
||||
channelId: parts[1],
|
||||
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${parts[1]}&parent=localhost`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// spotify
|
||||
if (urlp.hostname === 'open.spotify.com') {
|
||||
const [_, type, id] = urlp.pathname.split('/')
|
||||
if (type && id) {
|
||||
if (type === 'playlist') {
|
||||
return {
|
||||
type: 'spotify_playlist',
|
||||
playlistId: id,
|
||||
playerUri: `https://open.spotify.com/embed/playlist/${id}`,
|
||||
}
|
||||
}
|
||||
if (type === 'album') {
|
||||
return {
|
||||
type: 'spotify_album',
|
||||
albumId: id,
|
||||
playerUri: `https://open.spotify.com/embed/album/${id}`,
|
||||
}
|
||||
}
|
||||
if (type === 'track') {
|
||||
return {
|
||||
type: 'spotify_song',
|
||||
songId: id,
|
||||
playerUri: `https://open.spotify.com/embed/track/${id}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// soundcloud
|
||||
if (
|
||||
urlp.hostname === 'soundcloud.com' ||
|
||||
urlp.hostname === 'www.soundcloud.com'
|
||||
) {
|
||||
const [_, user, trackOrSets, set] = urlp.pathname.split('/')
|
||||
|
||||
if (user && trackOrSets) {
|
||||
if (trackOrSets === 'sets' && set) {
|
||||
return {
|
||||
type: 'soundcloud_set',
|
||||
user,
|
||||
set: set,
|
||||
playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'soundcloud_track',
|
||||
user,
|
||||
track: trackOrSets,
|
||||
playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getPlayerHeight({
|
||||
type,
|
||||
width,
|
||||
hasThumb,
|
||||
}: {
|
||||
type: EmbedPlayerParams['type']
|
||||
width: number
|
||||
hasThumb: boolean
|
||||
}) {
|
||||
if (!hasThumb) return (width / 16) * 9
|
||||
|
||||
switch (type) {
|
||||
case 'youtube_video':
|
||||
case 'twitch_live':
|
||||
return (width / 16) * 9
|
||||
case 'spotify_album':
|
||||
return 380
|
||||
case 'spotify_playlist':
|
||||
return 360
|
||||
case 'spotify_song':
|
||||
if (width <= 300) {
|
||||
return 180
|
||||
}
|
||||
return 232
|
||||
case 'soundcloud_track':
|
||||
return 165
|
||||
case 'soundcloud_set':
|
||||
return 360
|
||||
default:
|
||||
return width
|
||||
}
|
||||
}
|
||||
@@ -139,35 +139,6 @@ export function feedUriToHref(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function getYoutubeVideoId(link: string): string | undefined {
|
||||
let url
|
||||
try {
|
||||
url = new URL(link)
|
||||
} catch (e) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (
|
||||
url.hostname !== 'www.youtube.com' &&
|
||||
url.hostname !== 'youtube.com' &&
|
||||
url.hostname !== 'youtu.be'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
if (url.hostname === 'youtu.be') {
|
||||
const videoId = url.pathname.split('/')[1]
|
||||
if (!videoId) {
|
||||
return undefined
|
||||
}
|
||||
return videoId
|
||||
}
|
||||
const videoId = url.searchParams.get('v') as string
|
||||
if (!videoId) {
|
||||
return undefined
|
||||
}
|
||||
return videoId
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the label in the post text matches the host of the link facet.
|
||||
*
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7
-5
@@ -287,15 +287,11 @@ export class Logger {
|
||||
metadata: Metadata = {},
|
||||
) {
|
||||
if (!this.enabled) return
|
||||
if (!enabledLogLevels[this.level].includes(level)) return
|
||||
|
||||
const timestamp = Date.now()
|
||||
const meta = metadata || {}
|
||||
|
||||
for (const transport of this.transports) {
|
||||
transport(level, message, meta, timestamp)
|
||||
}
|
||||
|
||||
// send every log to syslog
|
||||
add({
|
||||
id: nanoid(),
|
||||
timestamp,
|
||||
@@ -303,6 +299,12 @@ export class Logger {
|
||||
message,
|
||||
metadata: meta,
|
||||
})
|
||||
|
||||
if (!enabledLogLevels[this.level].includes(level)) return
|
||||
|
||||
for (const transport of this.transports) {
|
||||
transport(level, message, meta, timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ let entries: ConsoleTransportEntry[] = []
|
||||
|
||||
export function add(entry: ConsoleTransportEntry) {
|
||||
entries.unshift(entry)
|
||||
entries = entries.slice(0, 50)
|
||||
entries = entries.slice(0, 500)
|
||||
}
|
||||
|
||||
export function getEntries() {
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Dimensions,
|
||||
RefreshControl,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {Dimensions, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import {List, ListRef} from '../util/List'
|
||||
import {FeedSourceCardLoaded} from './FeedSourceCard'
|
||||
@@ -180,22 +173,14 @@ export const ProfileFeedgens = React.forwardRef<
|
||||
data={items}
|
||||
keyExtractor={(item: any) => item._reactKey || item.uri}
|
||||
renderItem={renderItemInner}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
progressViewOffset={headerOffset}
|
||||
/>
|
||||
}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
headerOffset={headerOffset}
|
||||
contentContainerStyle={{
|
||||
minHeight: Dimensions.get('window').height * 1.5,
|
||||
}}
|
||||
style={{paddingTop: headerOffset}}
|
||||
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
|
||||
removeClippedSubviews={true}
|
||||
contentOffset={{x: 0, y: headerOffset * -1}}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
onEndReached={onEndReached}
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
RefreshControl,
|
||||
StyleProp,
|
||||
View,
|
||||
ViewStyle,
|
||||
@@ -15,7 +14,6 @@ import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
||||
import {ProfileCard} from '../profile/ProfileCard'
|
||||
import {Button} from '../util/forms/Button'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {useListMembersQuery} from '#/state/queries/list-members'
|
||||
import {logger} from '#/logger'
|
||||
@@ -51,7 +49,6 @@ export function ListMembers({
|
||||
headerOffset?: number
|
||||
desktopFixedHeightOffset?: number
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {track} = useAnalytics()
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false)
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
@@ -215,24 +212,16 @@ export function ListMembers({
|
||||
renderItem={renderItem}
|
||||
ListHeaderComponent={renderHeader}
|
||||
ListFooterComponent={Footer}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
progressViewOffset={headerOffset}
|
||||
/>
|
||||
}
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
headerOffset={headerOffset}
|
||||
contentContainerStyle={{
|
||||
minHeight: Dimensions.get('window').height * 1.5,
|
||||
}}
|
||||
style={{paddingTop: headerOffset}}
|
||||
onScrolledDownChange={onScrolledDownChange}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.6}
|
||||
removeClippedSubviews={true}
|
||||
contentOffset={{x: 0, y: headerOffset * -1}}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight={desktopFixedHeightOffset || true}
|
||||
/>
|
||||
|
||||
@@ -119,31 +119,51 @@ export function MyLists({
|
||||
[error, onRefresh, renderItem, pal],
|
||||
)
|
||||
|
||||
const FlatListCom = inline ? RNFlatList : List
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
{items.length > 0 && (
|
||||
<FlatListCom
|
||||
testID={testID ? `${testID}-flatlist` : undefined}
|
||||
data={items}
|
||||
keyExtractor={item => (item.uri ? item.uri : item._reactKey)}
|
||||
renderItem={renderItemInner}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={[s.contentContainer]}
|
||||
removeClippedSubviews={true}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
if (inline) {
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
{items.length > 0 && (
|
||||
<RNFlatList
|
||||
testID={testID ? `${testID}-flatlist` : undefined}
|
||||
data={items}
|
||||
keyExtractor={item => (item.uri ? item.uri : item._reactKey)}
|
||||
renderItem={renderItemInner}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={[s.contentContainer]}
|
||||
removeClippedSubviews={true}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
{items.length > 0 && (
|
||||
<List
|
||||
testID={testID ? `${testID}-flatlist` : undefined}
|
||||
data={items}
|
||||
keyExtractor={item => (item.uri ? item.uri : item._reactKey)}
|
||||
renderItem={renderItemInner}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
contentContainerStyle={[s.contentContainer]}
|
||||
removeClippedSubviews={true}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Dimensions,
|
||||
RefreshControl,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {Dimensions, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import {List, ListRef} from '../util/List'
|
||||
import {ListCard} from './ListCard'
|
||||
@@ -182,22 +175,14 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
|
||||
data={items}
|
||||
keyExtractor={(item: any) => item._reactKey}
|
||||
renderItem={renderItemInner}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
progressViewOffset={headerOffset}
|
||||
/>
|
||||
}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
headerOffset={headerOffset}
|
||||
contentContainerStyle={{
|
||||
minHeight: Dimensions.get('window').height * 1.5,
|
||||
}}
|
||||
style={{paddingTop: headerOffset}}
|
||||
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
|
||||
removeClippedSubviews={true}
|
||||
contentOffset={{x: 0, y: headerOffset * -1}}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
onEndReached={onEndReached}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import React from 'react'
|
||||
import {CenteredView} from '../util/Views'
|
||||
import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
|
||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
||||
import {FeedItem} from './FeedItem'
|
||||
import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
||||
import {EmptyState} from '../util/EmptyState'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
|
||||
import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread'
|
||||
import {logger} from '#/logger'
|
||||
@@ -30,7 +29,6 @@ export function Feed({
|
||||
onScrolledDownChange: (isScrolledDown: boolean) => void
|
||||
ListHeaderComponent?: () => JSX.Element
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
|
||||
const moderationOpts = useModerationOpts()
|
||||
@@ -152,14 +150,8 @@ export function Feed({
|
||||
renderItem={renderItem}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
ListFooterComponent={FeedFooter}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
/>
|
||||
}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.6}
|
||||
onScrolledDownChange={onScrolledDownChange}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import React, {useCallback, useMemo, useState} from 'react'
|
||||
import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
|
||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
||||
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
|
||||
import {CenteredView} from '../util/Views'
|
||||
import {List} from '../util/List'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {logger} from '#/logger'
|
||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||
import {usePostLikedByQuery} from '#/state/queries/post-liked-by'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
|
||||
export function PostLikedBy({uri}: {uri: string}) {
|
||||
const pal = usePalette('default')
|
||||
const [isPTRing, setIsPTRing] = useState(false)
|
||||
const {
|
||||
data: resolvedUri,
|
||||
@@ -88,14 +86,8 @@ export function PostLikedBy({uri}: {uri: string}) {
|
||||
<List
|
||||
data={likes}
|
||||
keyExtractor={item => item.actor.did}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
/>
|
||||
}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
renderItem={renderItem}
|
||||
initialNumToRender={15}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import React, {useMemo, useCallback, useState} from 'react'
|
||||
import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
|
||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
||||
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
|
||||
import {CenteredView} from '../util/Views'
|
||||
import {List} from '../util/List'
|
||||
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {logger} from '#/logger'
|
||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||
import {usePostRepostedByQuery} from '#/state/queries/post-reposted-by'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
|
||||
export function PostRepostedBy({uri}: {uri: string}) {
|
||||
const pal = usePalette('default')
|
||||
const [isPTRing, setIsPTRing] = useState(false)
|
||||
const {
|
||||
data: resolvedUri,
|
||||
@@ -89,14 +87,8 @@ export function PostRepostedBy({uri}: {uri: string}) {
|
||||
<List
|
||||
data={repostedBy}
|
||||
keyExtractor={item => item.did}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
/>
|
||||
}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
renderItem={renderItem}
|
||||
initialNumToRender={15}
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, {useEffect, useRef} from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
@@ -349,14 +348,8 @@ function PostThreadLoaded({
|
||||
}
|
||||
keyExtractor={item => item._reactKey}
|
||||
renderItem={renderItem}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onPTR}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
/>
|
||||
}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onPTR}
|
||||
onContentSizeChange={onContentSizeChange}
|
||||
style={s.hContentRegion}
|
||||
// @ts-ignore our .web version only -prf
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
ActivityIndicator,
|
||||
AppState,
|
||||
Dimensions,
|
||||
RefreshControl,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
@@ -16,7 +15,6 @@ import {FeedErrorMessage} from './FeedErrorMessage'
|
||||
import {FeedSlice} from './FeedSlice'
|
||||
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
@@ -74,7 +72,6 @@ let Feed = ({
|
||||
ListHeaderComponent?: () => JSX.Element
|
||||
extraData?: any
|
||||
}): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
const theme = useTheme()
|
||||
const {track} = useAnalytics()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -294,25 +291,17 @@ let Feed = ({
|
||||
renderItem={renderItem}
|
||||
ListFooterComponent={FeedFooter}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
progressViewOffset={headerOffset}
|
||||
/>
|
||||
}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
headerOffset={headerOffset}
|
||||
contentContainerStyle={{
|
||||
minHeight: Dimensions.get('window').height * 1.5,
|
||||
}}
|
||||
style={{paddingTop: headerOffset}}
|
||||
onScrolledDownChange={onScrolledDownChange}
|
||||
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={2} // number of posts left to trigger load more
|
||||
removeClippedSubviews={true}
|
||||
contentOffset={{x: 0, y: headerOffset * -1}}
|
||||
extraData={extraData}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight={
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import React from 'react'
|
||||
import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
|
||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
||||
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
|
||||
import {CenteredView} from '../util/Views'
|
||||
import {List} from '../util/List'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {ProfileCardWithFollowBtn} from './ProfileCard'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useProfileFollowersQuery} from '#/state/queries/profile-followers'
|
||||
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
|
||||
import {logger} from '#/logger'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
|
||||
export function ProfileFollowers({name}: {name: string}) {
|
||||
const pal = usePalette('default')
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
const {
|
||||
data: resolvedDid,
|
||||
@@ -90,14 +88,8 @@ export function ProfileFollowers({name}: {name: string}) {
|
||||
<List
|
||||
data={followers}
|
||||
keyExtractor={item => item.did}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
/>
|
||||
}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
renderItem={renderItem}
|
||||
initialNumToRender={15}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import React from 'react'
|
||||
import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
|
||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
||||
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
|
||||
import {CenteredView} from '../util/Views'
|
||||
import {List} from '../util/List'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {ProfileCardWithFollowBtn} from './ProfileCard'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
|
||||
import {logger} from '#/logger'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
|
||||
export function ProfileFollows({name}: {name: string}) {
|
||||
const pal = usePalette('default')
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
const {
|
||||
data: resolvedDid,
|
||||
@@ -90,14 +88,8 @@ export function ProfileFollows({name}: {name: string}) {
|
||||
<List
|
||||
data={follows}
|
||||
keyExtractor={item => item.did}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
/>
|
||||
}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
renderItem={renderItem}
|
||||
initialNumToRender={15}
|
||||
|
||||
@@ -1,27 +1,42 @@
|
||||
import React, {memo, startTransition} from 'react'
|
||||
import {FlatListProps} from 'react-native'
|
||||
import {FlatListProps, RefreshControl} from 'react-native'
|
||||
import {FlatList_INTERNAL} from './Views'
|
||||
import {addStyle} from 'lib/styles'
|
||||
import {useScrollHandlers} from '#/lib/ScrollContext'
|
||||
import {runOnJS, useSharedValue} from 'react-native-reanimated'
|
||||
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
|
||||
export type ListMethods = FlatList_INTERNAL
|
||||
export type ListProps<ItemT> = Omit<
|
||||
FlatListProps<ItemT>,
|
||||
'onScroll' // Use ScrollContext instead.
|
||||
| 'onScroll' // Use ScrollContext instead.
|
||||
| 'refreshControl' // Pass refreshing and/or onRefresh instead.
|
||||
| 'contentOffset' // Pass headerOffset instead.
|
||||
> & {
|
||||
onScrolledDownChange?: (isScrolledDown: boolean) => void
|
||||
headerOffset?: number
|
||||
refreshing?: boolean
|
||||
onRefresh?: () => void
|
||||
}
|
||||
export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
|
||||
|
||||
const SCROLLED_DOWN_LIMIT = 200
|
||||
|
||||
function ListImpl<ItemT>(
|
||||
{onScrolledDownChange, ...props}: ListProps<ItemT>,
|
||||
{
|
||||
onScrolledDownChange,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
headerOffset,
|
||||
style,
|
||||
...props
|
||||
}: ListProps<ItemT>,
|
||||
ref: React.Ref<ListMethods>,
|
||||
) {
|
||||
const isScrolledDown = useSharedValue(false)
|
||||
const contextScrollHandlers = useScrollHandlers()
|
||||
const pal = usePalette('default')
|
||||
|
||||
function handleScrolledDownChange(didScrollDown: boolean) {
|
||||
startTransition(() => {
|
||||
@@ -49,12 +64,36 @@ function ListImpl<ItemT>(
|
||||
},
|
||||
})
|
||||
|
||||
let refreshControl
|
||||
if (refreshing !== undefined || onRefresh !== undefined) {
|
||||
refreshControl = (
|
||||
<RefreshControl
|
||||
refreshing={refreshing ?? false}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
progressViewOffset={headerOffset}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
let contentOffset
|
||||
if (headerOffset != null) {
|
||||
style = addStyle(style, {
|
||||
paddingTop: headerOffset,
|
||||
})
|
||||
contentOffset = {x: 0, y: headerOffset * -1}
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList_INTERNAL
|
||||
{...props}
|
||||
scrollIndicatorInsets={{right: 1}}
|
||||
contentOffset={contentOffset}
|
||||
refreshControl={refreshControl}
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={1}
|
||||
style={style}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -6,22 +6,28 @@ import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {AppBskyEmbedExternal} from '@atproto/api'
|
||||
import {toNiceDomain} from 'lib/strings/url-helpers'
|
||||
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
|
||||
import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed'
|
||||
|
||||
export const ExternalLinkEmbed = ({
|
||||
link,
|
||||
imageChild,
|
||||
}: {
|
||||
link: AppBskyEmbedExternal.ViewExternal
|
||||
imageChild?: React.ReactNode
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
|
||||
const embedPlayerParams = React.useMemo(
|
||||
() => parseEmbedPlayerFromUrl(link.uri),
|
||||
[link.uri],
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
flexDirection: !isMobile && !embedPlayerParams ? 'row' : 'column',
|
||||
}}>
|
||||
{link.thumb ? (
|
||||
{link.thumb && !embedPlayerParams ? (
|
||||
<View
|
||||
style={
|
||||
!isMobile
|
||||
@@ -45,9 +51,11 @@ export const ExternalLinkEmbed = ({
|
||||
source={{uri: link.thumb}}
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
{imageChild}
|
||||
</View>
|
||||
) : undefined}
|
||||
{embedPlayerParams && (
|
||||
<ExternalPlayer link={link} params={embedPlayerParams} />
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: isMobile ? 10 : 14,
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
GestureResponderEvent,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {WebView} from 'react-native-webview'
|
||||
import YoutubePlayer from 'react-native-youtube-iframe'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {EmbedPlayerParams, getPlayerHeight} from 'lib/strings/embed-player'
|
||||
import {EventStopper} from '../EventStopper'
|
||||
import {AppBskyEmbedExternal} from '@atproto/api'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
|
||||
interface ShouldStartLoadRequest {
|
||||
url: string
|
||||
}
|
||||
|
||||
// This renders the overlay when the player is either inactive or loading as a separate layer
|
||||
function PlaceholderOverlay({
|
||||
isLoading,
|
||||
isPlayerActive,
|
||||
onPress,
|
||||
}: {
|
||||
isLoading: boolean
|
||||
isPlayerActive: boolean
|
||||
onPress: (event: GestureResponderEvent) => void
|
||||
}) {
|
||||
// If the player is active and not loading, we don't want to show the overlay.
|
||||
if (isPlayerActive && !isLoading) return null
|
||||
|
||||
return (
|
||||
<View style={[styles.layer, styles.overlayLayer]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Play Video"
|
||||
accessibilityHint=""
|
||||
onPress={onPress}
|
||||
style={[styles.overlayContainer, styles.topRadius]}>
|
||||
{!isPlayerActive ? (
|
||||
<FontAwesomeIcon icon="play" size={42} color="white" />
|
||||
) : (
|
||||
<ActivityIndicator size="large" color="white" />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// This renders the webview/youtube player as a separate layer
|
||||
function Player({
|
||||
height,
|
||||
params,
|
||||
onLoad,
|
||||
isPlayerActive,
|
||||
}: {
|
||||
isPlayerActive: boolean
|
||||
params: EmbedPlayerParams
|
||||
height: number
|
||||
onLoad: () => void
|
||||
}) {
|
||||
// ensures we only load what's requested
|
||||
const onShouldStartLoadWithRequest = React.useCallback(
|
||||
(event: ShouldStartLoadRequest) => event.url === params.playerUri,
|
||||
[params.playerUri],
|
||||
)
|
||||
|
||||
// Don't show the player until it is active
|
||||
if (!isPlayerActive) return null
|
||||
|
||||
return (
|
||||
<View style={[styles.layer, styles.playerLayer]}>
|
||||
<EventStopper>
|
||||
{isNative && params.type === 'youtube_video' ? (
|
||||
<YoutubePlayer
|
||||
videoId={params.videoId}
|
||||
play
|
||||
height={height}
|
||||
onReady={onLoad}
|
||||
webViewStyle={[styles.webview, styles.topRadius]}
|
||||
/>
|
||||
) : (
|
||||
<View style={{height, width: '100%'}}>
|
||||
<WebView
|
||||
javaScriptEnabled={true}
|
||||
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
|
||||
mediaPlaybackRequiresUserAction={false}
|
||||
allowsInlineMediaPlayback
|
||||
bounces={false}
|
||||
allowsFullscreenVideo
|
||||
nestedScrollEnabled
|
||||
source={{uri: params.playerUri}}
|
||||
onLoad={onLoad}
|
||||
setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
|
||||
style={[styles.webview, styles.topRadius]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</EventStopper>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// This renders the player area and handles the logic for when to show the player and when to show the overlay
|
||||
export function ExternalPlayer({
|
||||
link,
|
||||
params,
|
||||
}: {
|
||||
link: AppBskyEmbedExternal.ViewExternal
|
||||
params: EmbedPlayerParams
|
||||
}) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const [isPlayerActive, setPlayerActive] = React.useState(false)
|
||||
const [isLoading, setIsLoading] = React.useState(true)
|
||||
const [dim, setDim] = React.useState({
|
||||
width: 0,
|
||||
height: 0,
|
||||
})
|
||||
|
||||
const viewRef = React.useRef<View>(null)
|
||||
|
||||
// watch for leaving the viewport due to scrolling
|
||||
React.useEffect(() => {
|
||||
// Interval for scrolling works in most cases, However, for twitch embeds, if we navigate away from the screen the webview will
|
||||
// continue playing. We need to watch for the blur event
|
||||
const unsubscribe = navigation.addListener('blur', () => {
|
||||
setPlayerActive(false)
|
||||
})
|
||||
|
||||
const interval = setInterval(() => {
|
||||
viewRef.current?.measure((x, y, w, h, pageX, pageY) => {
|
||||
const window = Dimensions.get('window')
|
||||
const top = pageY
|
||||
const bot = pageY + h
|
||||
const isVisible = isNative
|
||||
? top >= 0 && bot <= window.height
|
||||
: !(top >= window.height || bot <= 0)
|
||||
if (!isVisible) {
|
||||
setPlayerActive(false)
|
||||
}
|
||||
})
|
||||
}, 1e3)
|
||||
return () => {
|
||||
unsubscribe()
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [viewRef, navigation])
|
||||
|
||||
// calculate height for the player and the screen size
|
||||
const height = React.useMemo(
|
||||
() =>
|
||||
getPlayerHeight({
|
||||
type: params.type,
|
||||
width: dim.width,
|
||||
hasThumb: !!link.thumb,
|
||||
}),
|
||||
[params.type, dim.width, link.thumb],
|
||||
)
|
||||
|
||||
const onLoad = React.useCallback(() => {
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
const onPlayPress = React.useCallback((event: GestureResponderEvent) => {
|
||||
// Prevent this from propagating upward on web
|
||||
event.preventDefault()
|
||||
|
||||
setPlayerActive(true)
|
||||
}, [])
|
||||
|
||||
// measure the layout to set sizing
|
||||
const onLayout = React.useCallback(
|
||||
(event: {nativeEvent: {layout: {width: any; height: any}}}) => {
|
||||
setDim({
|
||||
width: event.nativeEvent.layout.width,
|
||||
height: event.nativeEvent.layout.height,
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={viewRef}
|
||||
style={{height}}
|
||||
collapsable={false}
|
||||
onLayout={onLayout}>
|
||||
{link.thumb && (!isPlayerActive || isLoading) && (
|
||||
<Image
|
||||
style={[
|
||||
{
|
||||
width: dim.width,
|
||||
height,
|
||||
},
|
||||
styles.topRadius,
|
||||
]}
|
||||
source={{uri: link.thumb}}
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
)}
|
||||
|
||||
<PlaceholderOverlay
|
||||
isLoading={isLoading}
|
||||
isPlayerActive={isPlayerActive}
|
||||
onPress={onPlayPress}
|
||||
/>
|
||||
<Player
|
||||
isPlayerActive={isPlayerActive}
|
||||
params={params}
|
||||
height={height}
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
topRadius: {
|
||||
borderTopLeftRadius: 6,
|
||||
borderTopRightRadius: 6,
|
||||
},
|
||||
layer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
overlayContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
overlayLayer: {
|
||||
zIndex: 2,
|
||||
},
|
||||
playerLayer: {
|
||||
zIndex: 3,
|
||||
},
|
||||
webview: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
|
||||
import {AppBskyEmbedExternal} from '@atproto/api'
|
||||
import {Link} from '../Link'
|
||||
|
||||
export const YoutubeEmbed = ({
|
||||
link,
|
||||
style,
|
||||
}: {
|
||||
link: AppBskyEmbedExternal.ViewExternal
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
|
||||
const imageChild = (
|
||||
<View style={styles.playButton}>
|
||||
<FontAwesomeIcon icon="play" size={24} color="white" />
|
||||
</View>
|
||||
)
|
||||
|
||||
return (
|
||||
<Link
|
||||
asAnchor
|
||||
style={[styles.extOuter, pal.view, pal.border, style]}
|
||||
href={link.uri}>
|
||||
<ExternalLinkEmbed link={link} imageChild={imageChild} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
extOuter: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
},
|
||||
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',
|
||||
},
|
||||
})
|
||||
@@ -23,9 +23,7 @@ import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
||||
import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {YoutubeEmbed} from './YoutubeEmbed'
|
||||
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
|
||||
import {getYoutubeVideoId} from 'lib/strings/url-helpers'
|
||||
import {MaybeQuoteEmbed} from './QuoteEmbed'
|
||||
import {AutoSizedImage} from '../images/AutoSizedImage'
|
||||
import {ListEmbed} from './ListEmbed'
|
||||
@@ -168,19 +166,13 @@ export function PostEmbeds({
|
||||
// =
|
||||
if (AppBskyEmbedExternal.isView(embed)) {
|
||||
const link = embed.external
|
||||
const youtubeVideoId = getYoutubeVideoId(link.uri)
|
||||
|
||||
if (youtubeVideoId) {
|
||||
return <YoutubeEmbed link={link} style={style} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
asAnchor
|
||||
style={[styles.extOuter, pal.view, pal.border, style]}
|
||||
href={link.uri}>
|
||||
<ExternalLinkEmbed link={link} />
|
||||
</Link>
|
||||
<View style={[styles.extOuter, pal.view, pal.border, style]}>
|
||||
<Link asAnchor href={link.uri}>
|
||||
<ExternalLinkEmbed link={link} />
|
||||
</Link>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {ActivityIndicator, StyleSheet, View, RefreshControl} from 'react-native'
|
||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
|
||||
import {ViewHeader} from 'view/com/util/ViewHeader'
|
||||
@@ -487,14 +487,8 @@ export function FeedsScreen(_props: Props) {
|
||||
keyExtractor={item => item.key}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
renderItem={renderItem}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isPTR}
|
||||
onRefresh={isUserSearching ? undefined : onPullToRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
/>
|
||||
}
|
||||
refreshing={isPTR}
|
||||
onRefresh={isUserSearching ? undefined : onPullToRefresh}
|
||||
initialNumToRender={10}
|
||||
onEndReached={onEndReached}
|
||||
// @ts-ignore our .web version only -prf
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
View,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
TextInput,
|
||||
Pressable,
|
||||
Platform,
|
||||
@@ -185,7 +184,6 @@ type SearchResultSlice =
|
||||
|
||||
function SearchScreenPostResults({query}: {query: string}) {
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const [isPTR, setIsPTR] = React.useState(false)
|
||||
const {
|
||||
isFetched,
|
||||
@@ -254,14 +252,8 @@ function SearchScreenPostResults({query}: {query: string}) {
|
||||
}
|
||||
}}
|
||||
keyExtractor={item => item.key}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isPTR}
|
||||
onRefresh={onPullToRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
/>
|
||||
}
|
||||
refreshing={isPTR}
|
||||
onRefresh={onPullToRefresh}
|
||||
onEndReached={onEndReached}
|
||||
// @ts-ignore web only -prf
|
||||
desktopFixedHeight
|
||||
|
||||
@@ -10433,16 +10433,16 @@ escape-html@~1.0.3:
|
||||
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
|
||||
integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
|
||||
|
||||
escape-string-regexp@2.0.0, escape-string-regexp@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344"
|
||||
integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==
|
||||
|
||||
escape-string-regexp@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
|
||||
integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
|
||||
|
||||
escape-string-regexp@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344"
|
||||
integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==
|
||||
|
||||
escape-string-regexp@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
|
||||
@@ -12501,7 +12501,7 @@ interpret@^3.1.1:
|
||||
resolved "https://registry.yarnpkg.com/interpret/-/interpret-3.1.1.tgz#5be0ceed67ca79c6c4bc5cf0d7ee843dcea110c4"
|
||||
integrity sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==
|
||||
|
||||
invariant@*, invariant@^2.2.4:
|
||||
invariant@*, invariant@2.2.4, invariant@^2.2.4:
|
||||
version "2.2.4"
|
||||
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
|
||||
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
|
||||
@@ -17330,6 +17330,13 @@ qs@6.11.0:
|
||||
dependencies:
|
||||
side-channel "^1.0.4"
|
||||
|
||||
qs@^6.5.1:
|
||||
version "6.11.2"
|
||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9"
|
||||
integrity sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==
|
||||
dependencies:
|
||||
side-channel "^1.0.4"
|
||||
|
||||
query-string@^7.1.3:
|
||||
version "7.1.3"
|
||||
resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.3.tgz#a1cf90e994abb113a325804a972d98276fe02328"
|
||||
@@ -17535,12 +17542,12 @@ react-native-dotenv@^3.3.1:
|
||||
dependencies:
|
||||
dotenv "^16.3.1"
|
||||
|
||||
react-native-drawer-layout@^3.2.0:
|
||||
version "3.2.1"
|
||||
resolved "https://registry.yarnpkg.com/react-native-drawer-layout/-/react-native-drawer-layout-3.2.1.tgz#eb626216181965e72de6d9377ca619fab40226f2"
|
||||
integrity sha512-9GDzSLBblxox1zEACcbdXHMU8m22gCTdpT9g1G3Za4Bu766IDxVnIbgRGz6Z2dL99EMtz5E2JoWx257tdHkbpw==
|
||||
react-native-drawer-layout@^4.0.0-alpha.3:
|
||||
version "4.0.0-alpha.3"
|
||||
resolved "https://registry.yarnpkg.com/react-native-drawer-layout/-/react-native-drawer-layout-4.0.0-alpha.3.tgz#0adf51e816f2ce094d415fe33d9f43c1e6df6ae2"
|
||||
integrity sha512-BRlX8l2gDD41fs8RfaemjDBDV0PPspDMvej/3b9QWfp+JQ/H8tkSdWBtUBbSBzYdqyqy2bHV4epOCMb4t+0B0g==
|
||||
dependencies:
|
||||
use-latest-callback "^0.1.5"
|
||||
use-latest-callback "^0.1.9"
|
||||
|
||||
react-native-fs@^2.20.0:
|
||||
version "2.20.0"
|
||||
@@ -17680,6 +17687,13 @@ react-native-web-linear-gradient@^1.1.2:
|
||||
resolved "https://registry.yarnpkg.com/react-native-web-linear-gradient/-/react-native-web-linear-gradient-1.1.2.tgz#33f85f7085a0bb5ffa5106faf02ed105b92a9ed7"
|
||||
integrity sha512-SmUnpwT49CEe78pXvIvYf72Es8Pv+ZYKCnEOgb2zAKpEUDMo0+xElfRJhwt5nfI8krJ5WbFPKnoDgD0uUjAN1A==
|
||||
|
||||
react-native-web-webview@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-native-web-webview/-/react-native-web-webview-1.0.2.tgz#c215efa70c17589f2c8d640b1f1dc669b18c6e02"
|
||||
integrity sha512-oNAYNuqUqeqTuAAdIejzDqvUtYA+k5lrvhUYmASdUznZNmyIaoQFA6OKoA4K9F3wdMvark42vUXkUWIp875ewg==
|
||||
dependencies:
|
||||
qs "^6.5.1"
|
||||
|
||||
react-native-web@~0.19.6:
|
||||
version "0.19.8"
|
||||
resolved "https://registry.yarnpkg.com/react-native-web/-/react-native-web-0.19.8.tgz#46127f8b310148fde11e4fef67fe625603599d47"
|
||||
@@ -17694,6 +17708,21 @@ react-native-web@~0.19.6:
|
||||
postcss-value-parser "^4.2.0"
|
||||
styleq "^0.1.3"
|
||||
|
||||
react-native-webview@^13.6.2:
|
||||
version "13.6.2"
|
||||
resolved "https://registry.yarnpkg.com/react-native-webview/-/react-native-webview-13.6.2.tgz#0a9b18793e915add5b5dbdbf32509d7751b49167"
|
||||
integrity sha512-QzhQ5JCU+Nf2W285DtvCZOVQy/MkJXMwNDYPZvOWQbAOgxJMSSO+BtqXTMA1UPugDsko6PxJ0TxSlUwIwJijDg==
|
||||
dependencies:
|
||||
escape-string-regexp "2.0.0"
|
||||
invariant "2.2.4"
|
||||
|
||||
react-native-youtube-iframe@^2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/react-native-youtube-iframe/-/react-native-youtube-iframe-2.3.0.tgz#40ca8e55db929b91bfa8e8d30e411658cbc304c5"
|
||||
integrity sha512-M+z63xwXVtS4dX3k8PbtHUUcWN+gRZt6J1EtPE7Y60BMOB979KjpkdrHqeR96or9pNR2W8K5tQhIkMXW2jwo7Q==
|
||||
dependencies:
|
||||
events "^3.2.0"
|
||||
|
||||
react-native@0.72.5:
|
||||
version "0.72.5"
|
||||
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.72.5.tgz#2c343fa6f3ead362cf07376634a33a4078864357"
|
||||
@@ -20142,6 +20171,11 @@ use-latest-callback@^0.1.5:
|
||||
resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.1.6.tgz#3fa6e7babbb5f9bfa24b5094b22939e1e92ebcf6"
|
||||
integrity sha512-VO/P91A/PmKH9bcN9a7O3duSuxe6M14ZoYXgA6a8dab8doWNdhiIHzEkX/jFeTTRBsX0Ubk6nG4q2NIjNsj+bg==
|
||||
|
||||
use-latest-callback@^0.1.9:
|
||||
version "0.1.9"
|
||||
resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.1.9.tgz#10191dc54257e65a8e52322127643a8940271e2a"
|
||||
integrity sha512-CL/29uS74AwreI/f2oz2hLTW7ZqVeV5+gxFeGudzQrgkCytrHw33G4KbnQOrRlAEzzAFXi7dDLMC9zhWcVpzmw==
|
||||
|
||||
use-sidecar@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2"
|
||||
|
||||
Reference in New Issue
Block a user