Merge branch 'main' into web-layout

This commit is contained in:
Dan Abramov
2024-01-11 22:47:17 +00:00
245 changed files with 21884 additions and 4478 deletions
+24 -3
View File
@@ -1,5 +1,6 @@
import {resolveConfig} from 'detox/internals' import {resolveConfig} from 'detox/internals'
import {execSync} from 'child_process' import {execSync} from 'child_process'
import http from 'http'
const platform = device.getPlatform() const platform = device.getPlatform()
@@ -105,9 +106,29 @@ async function openAppForDebugBuild(platform: string, opts: any) {
} }
export async function createServer(path = '') { export async function createServer(path = '') {
const res = await fetch(`http://localhost:1986/${path}`, {method: 'POST'}) return new Promise(function (resolve, reject) {
const resBody = await res.text() var req = http.request(
return resBody {
method: 'POST',
host: 'localhost',
port: 1986,
path: `/${path}`,
},
function (res) {
const body: Buffer[] = []
res.on('data', chunk => body.push(chunk))
res.on('end', function () {
try {
resolve(Buffer.concat(body).toString())
} catch (e) {
reject(e)
}
})
},
)
req.on('error', reject)
req.end()
})
} }
const getDeepLinkUrl = (url: string) => const getDeepLinkUrl = (url: string) =>
+268 -26
View File
@@ -394,6 +394,7 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://youtube.com/watch?v=videoId', 'https://youtube.com/watch?v=videoId',
'https://youtube.com/watch?v=videoId&feature=share', 'https://youtube.com/watch?v=videoId&feature=share',
'https://youtube.com/shorts/videoId', 'https://youtube.com/shorts/videoId',
'https://m.youtube.com/watch?v=videoId',
'https://youtube.com/shorts/', 'https://youtube.com/shorts/',
'https://youtube.com/', 'https://youtube.com/',
@@ -401,113 +402,354 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://twitch.tv/channelName', 'https://twitch.tv/channelName',
'https://www.twitch.tv/channelName', 'https://www.twitch.tv/channelName',
'https://m.twitch.tv/channelName',
'https://twitch.tv/channelName/clip/clipId',
'https://twitch.tv/videos/videoId',
'https://open.spotify.com/playlist/playlistId', 'https://open.spotify.com/playlist/playlistId',
'https://open.spotify.com/playlist/playlistId?param=value', 'https://open.spotify.com/playlist/playlistId?param=value',
'https://open.spotify.com/locale/playlist/playlistId',
'https://open.spotify.com/track/songId', 'https://open.spotify.com/track/songId',
'https://open.spotify.com/track/songId?param=value', 'https://open.spotify.com/track/songId?param=value',
'https://open.spotify.com/locale/track/songId',
'https://open.spotify.com/album/albumId', 'https://open.spotify.com/album/albumId',
'https://open.spotify.com/album/albumId?param=value', 'https://open.spotify.com/album/albumId?param=value',
'https://open.spotify.com/locale/album/albumId',
'https://soundcloud.com/user/track', 'https://soundcloud.com/user/track',
'https://soundcloud.com/user/sets/set', 'https://soundcloud.com/user/sets/set',
'https://soundcloud.com/user/', 'https://soundcloud.com/user/',
'https://music.apple.com/us/playlist/playlistName/playlistId',
'https://music.apple.com/us/album/albumName/albumId',
'https://music.apple.com/us/album/albumName/albumId?i=songId',
'https://vimeo.com/videoId',
'https://vimeo.com/videoId?autoplay=0',
'https://giphy.com/gifs/some-random-gif-name-gifId',
'https://giphy.com/gif/some-random-gif-name-gifId',
'https://giphy.com/gifs/',
'https://media.giphy.com/media/gifId/giphy.webp',
'https://media0.giphy.com/media/gifId/giphy.webp',
'https://media1.giphy.com/media/gifId/giphy.gif',
'https://media2.giphy.com/media/gifId/giphy.webp',
'https://media3.giphy.com/media/gifId/giphy.mp4',
'https://media4.giphy.com/media/gifId/giphy.webp',
'https://media5.giphy.com/media/gifId/giphy.mp4',
'https://media0.giphy.com/media/gifId/giphy.mp3',
'https://media1.google.com/media/gifId/giphy.webp',
'https://media.giphy.com/media/trackingId/gifId/giphy.webp',
'https://i.giphy.com/media/gifId/giphy.webp',
'https://i.giphy.com/media/gifId/giphy.webp',
'https://i.giphy.com/gifId.gif',
'https://i.giphy.com/gifId.gif',
'https://tenor.com/view/gifId',
'https://tenor.com/notView/gifId',
'https://tenor.com/view',
'https://tenor.com/view/gifId.gif',
'https://tenor.com/intl/view/gifId.gif',
] ]
const outputs = [ const outputs = [
{ {
type: 'youtube_video', type: 'youtube_video',
videoId: 'videoId', source: 'youtube',
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1', playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
}, },
{ {
type: 'youtube_video', type: 'youtube_video',
videoId: 'videoId', source: 'youtube',
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1', playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
}, },
{ {
type: 'youtube_video', type: 'youtube_video',
videoId: 'videoId', source: 'youtube',
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1', playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
}, },
{ {
type: 'youtube_video', type: 'youtube_video',
videoId: 'videoId', source: 'youtube',
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1', playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
}, },
{ {
type: 'youtube_video', type: 'youtube_video',
videoId: 'videoId', source: 'youtube',
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1', playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
},
{
type: 'youtube_short',
source: 'youtubeShorts',
hideDetails: true,
playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
}, },
{ {
type: 'youtube_video', type: 'youtube_video',
videoId: 'videoId', source: 'youtube',
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1', playerUri:
'https://www.youtube.com/embed/videoId?autoplay=1&playsinline=1',
}, },
undefined, undefined,
undefined, undefined,
undefined, undefined,
{ {
type: 'twitch_live', type: 'twitch_video',
channelId: 'channelName', source: 'twitch',
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`, playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`,
}, },
{ {
type: 'twitch_live', type: 'twitch_video',
channelId: 'channelName', source: 'twitch',
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`, playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`,
}, },
{
type: 'twitch_video',
source: 'twitch',
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`,
},
{
type: 'twitch_video',
source: 'twitch',
playerUri: `https://clips.twitch.tv/embed?volume=0.5&autoplay=true&clip=clipId&parent=localhost`,
},
{
type: 'twitch_video',
source: 'twitch',
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&video=videoId&parent=localhost`,
},
{ {
type: 'spotify_playlist', type: 'spotify_playlist',
playlistId: 'playlistId', source: 'spotify',
playerUri: `https://open.spotify.com/embed/playlist/playlistId`, playerUri: `https://open.spotify.com/embed/playlist/playlistId`,
}, },
{ {
type: 'spotify_playlist', type: 'spotify_playlist',
playlistId: 'playlistId', source: 'spotify',
playerUri: `https://open.spotify.com/embed/playlist/playlistId`,
},
{
type: 'spotify_playlist',
source: 'spotify',
playerUri: `https://open.spotify.com/embed/playlist/playlistId`, playerUri: `https://open.spotify.com/embed/playlist/playlistId`,
}, },
{ {
type: 'spotify_song', type: 'spotify_song',
songId: 'songId', source: 'spotify',
playerUri: `https://open.spotify.com/embed/track/songId`, playerUri: `https://open.spotify.com/embed/track/songId`,
}, },
{ {
type: 'spotify_song', type: 'spotify_song',
songId: 'songId', source: 'spotify',
playerUri: `https://open.spotify.com/embed/track/songId`,
},
{
type: 'spotify_song',
source: 'spotify',
playerUri: `https://open.spotify.com/embed/track/songId`, playerUri: `https://open.spotify.com/embed/track/songId`,
}, },
{ {
type: 'spotify_album', type: 'spotify_album',
albumId: 'albumId', source: 'spotify',
playerUri: `https://open.spotify.com/embed/album/albumId`, playerUri: `https://open.spotify.com/embed/album/albumId`,
}, },
{ {
type: 'spotify_album', type: 'spotify_album',
albumId: 'albumId', source: 'spotify',
playerUri: `https://open.spotify.com/embed/album/albumId`,
},
{
type: 'spotify_album',
source: 'spotify',
playerUri: `https://open.spotify.com/embed/album/albumId`, playerUri: `https://open.spotify.com/embed/album/albumId`,
}, },
{ {
type: 'soundcloud_track', type: 'soundcloud_track',
user: 'user', source: 'soundcloud',
track: 'track',
playerUri: `https://w.soundcloud.com/player/?url=https://soundcloud.com/user/track&auto_play=true&visual=false&hide_related=true`, playerUri: `https://w.soundcloud.com/player/?url=https://soundcloud.com/user/track&auto_play=true&visual=false&hide_related=true`,
}, },
{ {
type: 'soundcloud_set', type: 'soundcloud_set',
user: 'user', source: 'soundcloud',
set: 'set',
playerUri: `https://w.soundcloud.com/player/?url=https://soundcloud.com/user/sets/set&auto_play=true&visual=false&hide_related=true`, playerUri: `https://w.soundcloud.com/player/?url=https://soundcloud.com/user/sets/set&auto_play=true&visual=false&hide_related=true`,
}, },
undefined, undefined,
{
type: 'apple_music_playlist',
source: 'appleMusic',
playerUri:
'https://embed.music.apple.com/us/playlist/playlistName/playlistId',
},
{
type: 'apple_music_album',
source: 'appleMusic',
playerUri: 'https://embed.music.apple.com/us/album/albumName/albumId',
},
{
type: 'apple_music_song',
source: 'appleMusic',
playerUri:
'https://embed.music.apple.com/us/album/albumName/albumId?i=songId',
},
{
type: 'vimeo_video',
source: 'vimeo',
playerUri: 'https://player.vimeo.com/video/videoId?autoplay=1',
},
{
type: 'vimeo_video',
source: 'vimeo',
playerUri: 'https://player.vimeo.com/video/videoId?autoplay=1',
},
{
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
},
undefined,
undefined,
{
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
},
{
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
},
{
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
},
{
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
},
{
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
},
{
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
},
undefined,
undefined,
undefined,
{
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
},
{
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
},
{
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
},
{
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
},
{
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: 'https://giphy.com/gifs/gifId',
playerUri: 'https://i.giphy.com/media/gifId/giphy.webp',
},
{
type: 'tenor_gif',
source: 'tenor',
isGif: true,
hideDetails: true,
playerUri: 'https://tenor.com/view/gifId.gif',
},
undefined,
undefined,
{
type: 'tenor_gif',
source: 'tenor',
isGif: true,
hideDetails: true,
playerUri: 'https://tenor.com/view/gifId.gif',
},
{
type: 'tenor_gif',
source: 'tenor',
isGif: true,
hideDetails: true,
playerUri: 'https://tenor.com/intl/view/gifId.gif',
},
] ]
it('correctly grabs the correct id from uri', () => { it('correctly grabs the correct id from uri', () => {
+25 -7
View File
@@ -1,5 +1,16 @@
const pkg = require('./package.json') const pkg = require('./package.json')
const SPLASH_CONFIG = {
backgroundColor: '#ffffff',
image: './assets/splash.png',
resizeMode: 'cover',
}
const DARK_SPLASH_CONFIG = {
backgroundColor: '#001429',
image: './assets/splash-dark.png',
resizeMode: 'cover',
}
module.exports = function () { module.exports = function () {
/** /**
* App version number. Should be incremented as part of a release cycle. * App version number. Should be incremented as part of a release cycle.
@@ -9,12 +20,12 @@ module.exports = function () {
/** /**
* iOS build number. Must be incremented for each TestFlight version. * iOS build number. Must be incremented for each TestFlight version.
*/ */
const IOS_BUILD_NUMBER = '5' const IOS_BUILD_NUMBER = '1'
/** /**
* Android build number. Must be incremented for each release. * Android build number. Must be incremented for each release.
*/ */
const ANDROID_VERSION_CODE = 51 const ANDROID_VERSION_CODE = 55
/** /**
* Uses built-in Expo env vars * Uses built-in Expo env vars
@@ -42,11 +53,7 @@ module.exports = function () {
orientation: 'portrait', orientation: 'portrait',
icon: './assets/icon.png', icon: './assets/icon.png',
userInterfaceStyle: 'automatic', userInterfaceStyle: 'automatic',
splash: { splash: SPLASH_CONFIG,
image: './assets/splash.png',
resizeMode: 'cover',
backgroundColor: '#ffffff',
},
ios: { ios: {
buildNumber: IOS_BUILD_NUMBER, buildNumber: IOS_BUILD_NUMBER,
supportsTablet: false, supportsTablet: false,
@@ -66,6 +73,10 @@ module.exports = function () {
'Used for profile pictures, posts, and other kinds of content', 'Used for profile pictures, posts, and other kinds of content',
}, },
associatedDomains: ['applinks:bsky.app', 'applinks:staging.bsky.app'], associatedDomains: ['applinks:bsky.app', 'applinks:staging.bsky.app'],
splash: {
...SPLASH_CONFIG,
dark: DARK_SPLASH_CONFIG,
},
}, },
androidStatusBar: { androidStatusBar: {
barStyle: 'dark-content', barStyle: 'dark-content',
@@ -95,6 +106,10 @@ module.exports = function () {
category: ['BROWSABLE', 'DEFAULT'], category: ['BROWSABLE', 'DEFAULT'],
}, },
], ],
splash: {
...SPLASH_CONFIG,
dark: DARK_SPLASH_CONFIG,
},
}, },
web: { web: {
favicon: './assets/favicon.png', favicon: './assets/favicon.png',
@@ -110,6 +125,9 @@ module.exports = function () {
[ [
'expo-build-properties', 'expo-build-properties',
{ {
ios: {
deploymentTarget: '13.4',
},
android: { android: {
compileSdkVersion: 34, compileSdkVersion: 34,
targetSdkVersion: 34, targetSdkVersion: 34,
Binary file not shown.

After

Width:  |  Height:  |  Size: 991 KiB

+1
View File
@@ -42,6 +42,7 @@ module.exports = function (api) {
platform: './src/platform', platform: './src/platform',
state: './src/state', state: './src/state',
view: './src/view', view: './src/view',
crypto: './src/platform/crypto.ts',
}, },
}, },
], ],
+28 -5
View File
@@ -4,6 +4,7 @@ import (
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"time" "time"
appbsky "github.com/bluesky-social/indigo/api/bsky" appbsky "github.com/bluesky-social/indigo/api/bsky"
@@ -39,11 +40,33 @@ type rss struct {
func (srv *Server) WebProfileRSS(c echo.Context) error { func (srv *Server) WebProfileRSS(c echo.Context) error {
ctx := c.Request().Context() ctx := c.Request().Context()
req := c.Request()
didParam := c.Param("did") identParam := c.Param("ident")
did, err := syntax.ParseDID(didParam)
// if not a DID, try parsing as a handle and doing a redirect
if !strings.HasPrefix(identParam, "did:") {
handle, err := syntax.ParseHandle(identParam)
if err != nil { if err != nil {
return echo.NewHTTPError(400, fmt.Sprintf("not a valid DID: %s", didParam)) return echo.NewHTTPError(400, fmt.Sprintf("not a valid handle: %s", identParam))
}
// check that public view is Ok, and resolve DID
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, handle.String())
if err != nil {
return echo.NewHTTPError(404, fmt.Sprintf("account not found: %s", handle))
}
for _, label := range pv.Labels {
if label.Src == pv.Did && label.Val == "!no-unauthenticated" {
return echo.NewHTTPError(403, fmt.Sprintf("account does not allow public views: %s", handle))
}
}
return c.Redirect(http.StatusFound, fmt.Sprintf("/profile/%s/rss", pv.Did))
}
did, err := syntax.ParseDID(identParam)
if err != nil {
return echo.NewHTTPError(400, fmt.Sprintf("not a valid DID: %s", identParam))
} }
// check that public view is Ok // check that public view is Ok
@@ -84,7 +107,7 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
pubDate = createdAt.Time().Format(time.RFC822Z) pubDate = createdAt.Time().Format(time.RFC822Z)
} }
posts = append(posts, Item{ posts = append(posts, Item{
Link: fmt.Sprintf("https://bsky.app/profile/%s/post/%s", pv.Handle, aturi.RecordKey().String()), Link: fmt.Sprintf("https://%s/profile/%s/post/%s", req.Host, pv.Handle, aturi.RecordKey().String()),
Description: rec.Text, Description: rec.Text,
PubDate: pubDate, PubDate: pubDate,
GUID: ItemGUID{ GUID: ItemGUID{
@@ -105,7 +128,7 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
feed := &rss{ feed := &rss{
Version: "2.0", Version: "2.0",
Description: desc, Description: desc,
Link: fmt.Sprintf("https://bsky.app/profile/%s", pv.Handle), Link: fmt.Sprintf("https://%s/profile/%s", req.Host, pv.Handle),
Title: title, Title: title,
Item: posts, Item: posts,
} }
+8 -2
View File
@@ -193,6 +193,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/home-feed", server.WebGeneric) e.GET("/settings/home-feed", server.WebGeneric)
e.GET("/settings/saved-feeds", server.WebGeneric) e.GET("/settings/saved-feeds", server.WebGeneric)
e.GET("/settings/threads", server.WebGeneric) e.GET("/settings/threads", server.WebGeneric)
e.GET("/settings/external-embeds", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric) e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric) e.GET("/sys/log", server.WebGeneric)
e.GET("/support", server.WebGeneric) e.GET("/support", server.WebGeneric)
@@ -210,7 +211,7 @@ func serve(cctx *cli.Context) error {
e.GET("/profile/:handle/feed/:rkey/liked-by", server.WebGeneric) e.GET("/profile/:handle/feed/:rkey/liked-by", server.WebGeneric)
// profile RSS feed (DID not handle) // profile RSS feed (DID not handle)
e.GET("/profile/:did/rss", server.WebProfileRSS) e.GET("/profile/:ident/rss", server.WebProfileRSS)
// post endpoints; only first populates info // post endpoints; only first populates info
e.GET("/profile/:handle/post/:rkey", server.WebPost) e.GET("/profile/:handle/post/:rkey", server.WebPost)
@@ -336,7 +337,11 @@ func (srv *Server) WebPost(c echo.Context) error {
data["postView"] = postView data["postView"] = postView
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path) data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
if postView.Embed != nil && postView.Embed.EmbedImages_View != nil { if postView.Embed != nil && postView.Embed.EmbedImages_View != nil {
data["imgThumbUrl"] = postView.Embed.EmbedImages_View.Images[0].Thumb var thumbUrls []string
for i := range postView.Embed.EmbedImages_View.Images {
thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb)
}
data["imgThumbUrls"] = thumbUrls
} }
return c.Render(http.StatusOK, "post.html", data) return c.Render(http.StatusOK, "post.html", data)
} }
@@ -370,6 +375,7 @@ func (srv *Server) WebProfile(c echo.Context) error {
req := c.Request() req := c.Request()
data["profileView"] = pv data["profileView"] = pv
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path) data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
data["requestHost"] = req.Host
return c.Render(http.StatusOK, "profile.html", data) return c.Render(http.StatusOK, "profile.html", data)
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 769 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+2
View File
@@ -48,6 +48,7 @@
--text: white; --text: white;
--background: black; --background: black;
--backgroundLight: #26272D; --backgroundLight: #26272D;
color-scheme: dark;
} }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
html.colorMode--system { html.colorMode--system {
@@ -61,6 +62,7 @@
--text: white; --text: white;
--background: black; --background: black;
--backgroundLight: #26272D; --backgroundLight: #26272D;
color-scheme: dark;
} }
} }
+3 -1
View File
@@ -25,8 +25,10 @@
<meta name="description" content="{{ postView.Record.Val.Text }}"> <meta name="description" content="{{ postView.Record.Val.Text }}">
<meta property="og:description" content="{{ postView.Record.Val.Text }}"> <meta property="og:description" content="{{ postView.Record.Val.Text }}">
{% endif -%} {% endif -%}
{%- if imgThumbUrl %} {%- if imgThumbUrls %}
{% for imgThumbUrl in imgThumbUrls %}
<meta property="og:image" content="{{ imgThumbUrl }}"> <meta property="og:image" content="{{ imgThumbUrl }}">
{% endfor %}
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
{%- elif postView.Author.Avatar %} {%- elif postView.Author.Avatar %}
{# Don't use avatar image in cards; usually looks bad #} {# Don't use avatar image in cards; usually looks bad #}
+3 -1
View File
@@ -34,7 +34,9 @@
{% endif %} {% endif %}
<meta name="twitter:label1" content="Account DID"> <meta name="twitter:label1" content="Account DID">
<meta name="twitter:value1" content="{{ profileView.Did }}"> <meta name="twitter:value1" content="{{ profileView.Did }}">
<link rel="alternate" type="application/rss+xml" href="/profile/{{ profileView.Did }}/rss"> {%- if requestHost %}
<link rel="alternate" type="application/rss+xml" href="https://{{ requestHost }}/profile/{{ profileView.Did }}/rss">
{% endif %}
{% endif -%} {% endif -%}
{%- endblock %} {%- endblock %}
+1 -1
View File
@@ -1,6 +1,6 @@
/** @type {import('@lingui/conf').LinguiConfig} */ /** @type {import('@lingui/conf').LinguiConfig} */
module.exports = { module.exports = {
locales: ['en', 'hi', 'ja', 'fr', 'de', 'es'], locales: ['en', 'de', 'es', 'fr', 'hi', 'id', 'ja', 'ko', 'pt-BR', 'uk'],
catalogs: [ catalogs: [
{ {
path: '<rootDir>/src/locale/locales/{locale}/messages', path: '<rootDir>/src/locale/locales/{locale}/messages',
+49 -42
View File
@@ -1,7 +1,10 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.60.0", "version": "1.63.0",
"private": true, "private": true,
"engines": {
"node": ">=18"
},
"scripts": { "scripts": {
"prepare": "is-ci || husky install", "prepare": "is-ci || husky install",
"postinstall": "patch-package && yarn intl:compile", "postinstall": "patch-package && yarn intl:compile",
@@ -20,7 +23,7 @@
"test-coverage": "NODE_ENV=test jest --coverage", "test-coverage": "NODE_ENV=test jest --coverage",
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx", "lint": "eslint ./src --ext .js,.jsx,.ts,.tsx",
"typecheck": "tsc --project ./tsconfig.check.json", "typecheck": "tsc --project ./tsconfig.check.json",
"e2e:mock-server": "./jest/dev-infra/with-test-redis-and-db.sh ts-node __e2e__/mock-server.ts", "e2e:mock-server": "./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts",
"e2e:metro": "NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios", "e2e:metro": "NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:build": "NODE_ENV=test detox build -c ios.sim.debug", "e2e:build": "NODE_ENV=test detox build -c ios.sim.debug",
"e2e:run": "NODE_ENV=test detox test --configuration ios.sim.debug --take-screenshots all", "e2e:run": "NODE_ENV=test detox test --configuration ios.sim.debug --take-screenshots all",
@@ -32,10 +35,11 @@
"intl:build": "yarn intl:check && yarn intl:compile", "intl:build": "yarn intl:check && yarn intl:compile",
"intl:check": "yarn intl:extract && git diff-index -G'(^[^\\*# /])|(^#\\w)|(^\\s+[^\\*#/])' HEAD || (echo '\n⚠️ i18n detected un-extracted translations\n' && exit 1)", "intl:check": "yarn intl:extract && git diff-index -G'(^[^\\*# /])|(^#\\w)|(^\\s+[^\\*#/])' HEAD || (echo '\n⚠️ i18n detected un-extracted translations\n' && exit 1)",
"intl:extract": "lingui extract", "intl:extract": "lingui extract",
"intl:compile": "lingui compile" "intl:compile": "lingui compile",
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.7.4", "@atproto/api": "^0.8.0",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
@@ -49,14 +53,14 @@
"@lingui/react": "^4.5.0", "@lingui/react": "^4.5.0",
"@mattermost/react-native-paste-input": "^0.6.4", "@mattermost/react-native-paste-input": "^0.6.4",
"@miblanchard/react-native-slider": "^2.3.1", "@miblanchard/react-native-slider": "^2.3.1",
"@react-native-async-storage/async-storage": "1.18.2", "@react-native-async-storage/async-storage": "1.21.0",
"@react-native-camera-roll/camera-roll": "^5.2.2", "@react-native-camera-roll/camera-roll": "^5.2.2",
"@react-native-clipboard/clipboard": "^1.10.0", "@react-native-clipboard/clipboard": "^1.10.0",
"@react-native-community/blur": "^4.3.0", "@react-native-community/blur": "^4.3.0",
"@react-native-community/datetimepicker": "7.2.0", "@react-native-community/datetimepicker": "7.6.1",
"@react-native-masked-view/masked-view": "^0.3.1", "@react-native-masked-view/masked-view": "0.3.0",
"@react-native-menu/menu": "^0.8.0", "@react-native-menu/menu": "^0.8.0",
"@react-native-picker/picker": "2.4.10", "@react-native-picker/picker": "2.5.1",
"@react-navigation/bottom-tabs": "^6.5.7", "@react-navigation/bottom-tabs": "^6.5.7",
"@react-navigation/drawer": "^6.6.2", "@react-navigation/drawer": "^6.6.2",
"@react-navigation/native": "^6.1.6", "@react-navigation/native": "^6.1.6",
@@ -65,7 +69,7 @@
"@segment/analytics-react": "^1.0.0-rc1", "@segment/analytics-react": "^1.0.0-rc1",
"@segment/analytics-react-native": "^2.10.1", "@segment/analytics-react-native": "^2.10.1",
"@segment/sovran-react-native": "^0.4.5", "@segment/sovran-react-native": "^0.4.5",
"@sentry/react-native": "5.10.0", "@sentry/react-native": "5.5.0",
"@tanstack/react-query": "^5.8.1", "@tanstack/react-query": "^5.8.1",
"@tiptap/core": "^2.0.0-beta.220", "@tiptap/core": "^2.0.0-beta.220",
"@tiptap/extension-document": "^2.0.0-beta.220", "@tiptap/extension-document": "^2.0.0-beta.220",
@@ -90,24 +94,25 @@
"email-validator": "^2.0.4", "email-validator": "^2.0.4",
"emoji-mart": "^5.5.2", "emoji-mart": "^5.5.2",
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
"expo": "^49.0.8", "expo": "^50.0.0-preview.7",
"expo-application": "~5.3.0", "expo-application": "~5.8.1",
"expo-build-properties": "~0.8.3", "expo-build-properties": "^0.11.0",
"expo-camera": "13.5.1", "expo-camera": "~14.0.1",
"expo-constants": "~14.4.2", "expo-constants": "~15.4.2",
"expo-dev-client": "2.4.7", "expo-dev-client": "~3.3.4",
"expo-device": "~5.4.0", "expo-device": "~5.9.1",
"expo-image": "~1.3.2", "expo-image": "~1.10.1",
"expo-image-manipulator": "~11.5.0", "expo-image-manipulator": "^11.8.0",
"expo-image-picker": "~14.5.0", "expo-image-picker": "~14.7.1",
"expo-localization": "~14.3.0", "expo-localization": "~14.8.1",
"expo-media-library": "~15.4.1", "expo-media-library": "~15.9.1",
"expo-notifications": "~0.20.1", "expo-notifications": "~0.27.2",
"expo-sharing": "~11.5.0", "expo-sharing": "^11.10.0",
"expo-splash-screen": "~0.20.5", "expo-splash-screen": "~0.26.1",
"expo-status-bar": "~1.6.0", "expo-status-bar": "~1.11.1",
"expo-system-ui": "~2.4.0", "expo-system-ui": "~2.9.2",
"expo-updates": "~0.18.12", "expo-task-manager": "~11.7.0",
"expo-updates": "~0.24.5",
"fast-text-encoding": "^1.0.6", "fast-text-encoding": "^1.0.6",
"history": "^5.3.0", "history": "^5.3.0",
"js-sha256": "^0.9.0", "js-sha256": "^0.9.0",
@@ -135,34 +140,32 @@
"react-avatar-editor": "^13.0.0", "react-avatar-editor": "^13.0.0",
"react-circular-progressbar": "^2.1.0", "react-circular-progressbar": "^2.1.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-native": "0.72.5", "react-native": "0.73.1",
"react-native-appstate-hook": "^1.0.6", "react-native-appstate-hook": "^1.0.6",
"react-native-drawer-layout": "^4.0.0-alpha.3", "react-native-drawer-layout": "^4.0.0-alpha.3",
"react-native-fs": "^2.20.0", "react-native-fs": "^2.20.0",
"react-native-gesture-handler": "^2.12.1", "react-native-gesture-handler": "~2.14.0",
"react-native-get-random-values": "^1.8.0", "react-native-get-random-values": "~1.8.0",
"react-native-haptic-feedback": "^1.14.0", "react-native-haptic-feedback": "^1.14.0",
"react-native-image-crop-picker": "^0.38.1", "react-native-image-crop-picker": "^0.38.1",
"react-native-inappbrowser-reborn": "^3.6.3", "react-native-inappbrowser-reborn": "^3.6.3",
"react-native-ios-context-menu": "^1.15.3", "react-native-ios-context-menu": "^1.15.3",
"react-native-linear-gradient": "^2.6.2", "react-native-linear-gradient": "^2.6.2",
"react-native-pager-view": "6.1.4", "react-native-pager-view": "6.2.2",
"react-native-picker-select": "^8.1.0", "react-native-picker-select": "^8.1.0",
"react-native-progress": "bluesky-social/react-native-progress", "react-native-progress": "bluesky-social/react-native-progress",
"react-native-reanimated": "^3.6.0", "react-native-reanimated": "^3.6.0",
"react-native-root-siblings": "^4.1.1", "react-native-root-siblings": "^4.1.1",
"react-native-safe-area-context": "4.6.3", "react-native-safe-area-context": "4.7.4",
"react-native-screens": "~3.22.0", "react-native-screens": "~3.27.0",
"react-native-splash-screen": "^3.3.0", "react-native-svg": "14.0.0",
"react-native-svg": "13.9.0",
"react-native-url-polyfill": "^1.3.0", "react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.1", "react-native-uuid": "^2.0.1",
"react-native-version-number": "^0.3.6", "react-native-version-number": "^0.3.6",
"react-native-web": "~0.19.6", "react-native-web": "~0.19.6",
"react-native-web-linear-gradient": "^1.1.2", "react-native-web-linear-gradient": "^1.1.2",
"react-native-web-webview": "^1.0.2", "react-native-web-webview": "^1.0.2",
"react-native-webview": "^13.6.2", "react-native-webview": "^13.6.3",
"react-native-youtube-iframe": "^2.3.0",
"react-responsive": "^9.0.2", "react-responsive": "^9.0.2",
"rn-fetch-blob": "^0.12.0", "rn-fetch-blob": "^0.12.0",
"sentry-expo": "~7.0.1", "sentry-expo": "~7.0.1",
@@ -178,10 +181,13 @@
"@babel/preset-env": "^7.20.0", "@babel/preset-env": "^7.20.0",
"@babel/runtime": "^7.20.0", "@babel/runtime": "^7.20.0",
"@did-plc/server": "^0.0.1", "@did-plc/server": "^0.0.1",
"@expo/config-plugins": "7.8.0",
"@expo/prebuild-config": "6.7.0",
"@lingui/cli": "^4.5.0", "@lingui/cli": "^4.5.0",
"@lingui/macro": "^4.5.0", "@lingui/macro": "^4.5.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11",
"@react-native-community/eslint-config": "^3.0.0", "@react-native-community/eslint-config": "^3.0.0",
"@react-native/typescript-config": "^0.74.0",
"@testing-library/jest-native": "^5.4.1", "@testing-library/jest-native": "^5.4.1",
"@testing-library/react-native": "^11.5.2", "@testing-library/react-native": "^11.5.2",
"@tsconfig/react-native": "^2.0.3", "@tsconfig/react-native": "^2.0.3",
@@ -203,12 +209,13 @@
"@types/react-test-renderer": "^17.0.1", "@types/react-test-renderer": "^17.0.1",
"@typescript-eslint/eslint-plugin": "^5.48.2", "@typescript-eslint/eslint-plugin": "^5.48.2",
"@typescript-eslint/parser": "^5.48.2", "@typescript-eslint/parser": "^5.48.2",
"babel-jest": "^29.4.2", "babel-jest": "^29.7.0",
"babel-loader": "^9.1.2", "babel-loader": "^9.1.2",
"babel-plugin-macros": "^3.1.0", "babel-plugin-macros": "^3.1.0",
"babel-plugin-module-resolver": "^5.0.0", "babel-plugin-module-resolver": "^5.0.0",
"babel-plugin-react-native-web": "^0.18.12", "babel-plugin-react-native-web": "^0.18.12",
"detox": "^20.13.0", "babel-preset-expo": "^10.0.0",
"detox": "^20.14.8",
"eslint": "^8.19.0", "eslint": "^8.19.0",
"eslint-plugin-detox": "^1.0.0", "eslint-plugin-detox": "^1.0.0",
"eslint-plugin-ft-flow": "^2.0.3", "eslint-plugin-ft-flow": "^2.0.3",
@@ -218,8 +225,8 @@
"html-webpack-plugin": "^5.5.0", "html-webpack-plugin": "^5.5.0",
"husky": "^8.0.3", "husky": "^8.0.3",
"is-ci": "^3.0.1", "is-ci": "^3.0.1",
"jest": "^29.4.3", "jest": "^29.7.0",
"jest-expo": "^49.0.0", "jest-expo": "^50.0.1",
"jest-junit": "^15.0.0", "jest-junit": "^15.0.0",
"lint-staged": "^13.2.3", "lint-staged": "^13.2.3",
"metro-react-native-babel-preset": "^0.73.7", "metro-react-native-babel-preset": "^0.73.7",
@@ -229,7 +236,7 @@
"react-scripts": "^5.0.1", "react-scripts": "^5.0.1",
"react-test-renderer": "18.2.0", "react-test-renderer": "18.2.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.1.3", "typescript": "^5.3.3",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"webpack": "^5.75.0", "webpack": "^5.75.0",
"webpack-cli": "^5.0.1", "webpack-cli": "^5.0.1",
-14
View File
@@ -1,14 +0,0 @@
diff --git a/node_modules/babel-preset-expo/index.js b/node_modules/babel-preset-expo/index.js
index 2099ee3..2b9e092 100644
--- a/node_modules/babel-preset-expo/index.js
+++ b/node_modules/babel-preset-expo/index.js
@@ -105,7 +105,8 @@ module.exports = function (api, options = {}) {
],
],
plugins: [
- getObjectRestSpreadPlugin(),
+ // - dan: This will be disabled anyway when we upgrade Expo, but let's do it now.
+ // getObjectRestSpreadPlugin(),
...extraPlugins,
getAliasPlugin(),
[require.resolve('@babel/plugin-proposal-decorators'), { legacy: true }],
+64
View File
@@ -0,0 +1,64 @@
diff --git a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/contracts/ImageLibraryContract.kt b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/contracts/ImageLibraryContract.kt
index ff15c91..41aaf12 100644
--- a/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/contracts/ImageLibraryContract.kt
+++ b/node_modules/expo-image-picker/android/src/main/java/expo/modules/imagepicker/contracts/ImageLibraryContract.kt
@@ -26,51 +26,26 @@ import java.io.Serializable
* @see [androidx.activity.result.contract.ActivityResultContracts.GetMultipleContents]
*/
internal class ImageLibraryContract(
- private val appContextProvider: AppContextProvider
+ private val appContextProvider: AppContextProvider,
) : AppContextActivityResultContract<ImageLibraryContractOptions, ImagePickerContractResult> {
private val contentResolver: ContentResolver
get() = appContextProvider.appContext.reactContext?.contentResolver
?: throw Exceptions.ReactContextLost()
override fun createIntent(context: Context, input: ImageLibraryContractOptions): Intent {
- val request = PickVisualMediaRequest.Builder()
- .setMediaType(
- when (input.options.mediaTypes) {
- MediaTypes.VIDEOS -> {
- PickVisualMedia.VideoOnly
- }
-
- MediaTypes.IMAGES -> {
- PickVisualMedia.ImageOnly
- }
-
- else -> {
- PickVisualMedia.ImageAndVideo
- }
- }
- )
- .build()
+ val intent = Intent(Intent.ACTION_GET_CONTENT)
+ .addCategory(Intent.CATEGORY_OPENABLE)
+ .setType("image/*")
if (input.options.allowsMultipleSelection) {
- val selectionLimit = input.options.selectionLimit
-
- if (selectionLimit == 1) {
- // If multiple selection is allowed but the limit is 1, we should ignore
- // the multiple selection flag and just treat it as a single selection.
- return PickVisualMedia().createIntent(context, request)
+ if(input.options.selectionLimit == 1) {
+ return intent
}
- if (selectionLimit > 1) {
- return PickMultipleVisualMedia(selectionLimit).createIntent(context, request)
- }
-
- // If the selection limit is 0, it is the same as unlimited selection.
- if (selectionLimit == UNLIMITED_SELECTION) {
- return PickMultipleVisualMedia().createIntent(context, request)
- }
+ intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}
- return PickVisualMedia().createIntent(context, request)
+ return intent
}
override fun parseResult(input: ImageLibraryContractOptions, resultCode: Int, intent: Intent?) =
@@ -0,0 +1,3 @@
added by https://github.com/bluesky-social/social-app/pull/2384#pullrequestreview-1800985521
hackfixes the image picker on android so that the user can select from their typical image sources
@@ -1,8 +1,8 @@
diff --git a/node_modules/metro-transform-worker/src/index.js b/node_modules/metro-transform-worker/src/index.js diff --git a/node_modules/metro-transform-worker/src/index.js b/node_modules/metro-transform-worker/src/index.js
index 27d4cb3..fd71f47 100644 index cae11e7..42f251b 100644
--- a/node_modules/metro-transform-worker/src/index.js --- a/node_modules/metro-transform-worker/src/index.js
+++ b/node_modules/metro-transform-worker/src/index.js +++ b/node_modules/metro-transform-worker/src/index.js
@@ -190,6 +190,10 @@ async function transformJS(file, { config, options, projectRoot }) { @@ -189,6 +189,10 @@ async function transformJS(file, { config, options, projectRoot }) {
let dependencyMapName = ""; let dependencyMapName = "";
let dependencies; let dependencies;
let wrappedAst; let wrappedAst;
@@ -13,7 +13,7 @@ index 27d4cb3..fd71f47 100644
// If the module to transform is a script (meaning that is not part of the // If the module to transform is a script (meaning that is not part of the
// dependency graph and it code will just be prepended to the bundle modules), // dependency graph and it code will just be prepended to the bundle modules),
@@ -229,19 +233,20 @@ async function transformJS(file, { config, options, projectRoot }) { @@ -228,19 +232,20 @@ async function transformJS(file, { config, options, projectRoot }) {
if (config.unstable_disableModuleWrapping === true) { if (config.unstable_disableModuleWrapping === true) {
wrappedAst = ast; wrappedAst = ast;
} else { } else {
@@ -1,7 +1,7 @@
diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.m b/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.m diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm
index 9dca6a5..090bda5 100644 index 9dca6a5..090bda5 100644
--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.m --- a/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm
+++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.m +++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm
@@ -266,11 +266,10 @@ static void *TextFieldSelectionObservingContext = &TextFieldSelectionObservingCo @@ -266,11 +266,10 @@ static void *TextFieldSelectionObservingContext = &TextFieldSelectionObservingCo
- (void)textViewDidChangeSelection:(__unused UITextView *)textView - (void)textViewDidChangeSelection:(__unused UITextView *)textView
@@ -1,54 +0,0 @@
diff --git a/node_modules/react-native-pager-view/ios/ReactNativePageView.m b/node_modules/react-native-pager-view/ios/ReactNativePageView.m
index ab0fc7f..1ace752 100644
--- a/node_modules/react-native-pager-view/ios/ReactNativePageView.m
+++ b/node_modules/react-native-pager-view/ios/ReactNativePageView.m
@@ -1,6 +1,6 @@
#import "ReactNativePageView.h"
-#import "React/RCTLog.h"
+#import <React/RCTLog.h>
#import <React/RCTViewManager.h>
#import "UIViewController+CreateExtension.h"
@@ -9,7 +9,7 @@
#import "RCTOnPageSelected.h"
#import <math.h>
-@interface ReactNativePageView () <UIPageViewControllerDataSource, UIPageViewControllerDelegate, UIScrollViewDelegate>
+@interface ReactNativePageView () <UIPageViewControllerDataSource, UIPageViewControllerDelegate, UIScrollViewDelegate, UIGestureRecognizerDelegate>
@property(nonatomic, strong) UIPageViewController *reactPageViewController;
@property(nonatomic, strong) RCTEventDispatcher *eventDispatcher;
@@ -80,6 +80,10 @@ - (void)didMoveToWindow {
[self setupInitialController];
}
+ UIPanGestureRecognizer* panGestureRecognizer = [UIPanGestureRecognizer new];
+ panGestureRecognizer.delegate = self;
+ [self addGestureRecognizer: panGestureRecognizer];
+
if (self.reactViewController.navigationController != nil && self.reactViewController.navigationController.interactivePopGestureRecognizer != nil) {
[self.scrollView.panGestureRecognizer requireGestureRecognizerToFail:self.reactViewController.navigationController.interactivePopGestureRecognizer];
}
@@ -463,4 +467,21 @@ - (NSString *)determineScrollDirection:(UIScrollView *)scrollView {
- (BOOL)isLtrLayout {
return [_layoutDirection isEqualToString:@"ltr"];
}
+
+- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
+ if (!_overdrag && otherGestureRecognizer == self.scrollView.panGestureRecognizer) {
+ UIPanGestureRecognizer* p = (UIPanGestureRecognizer*) gestureRecognizer;
+ CGPoint velocity = [p velocityInView:self];
+ if (self.currentIndex == 0 && velocity.x > 0) {
+ self.scrollView.panGestureRecognizer.enabled = false;
+ return NO;
+ } else {
+ self.scrollView.panGestureRecognizer.enabled = self.scrollEnabled;
+ }
+ } else {
+ self.scrollView.panGestureRecognizer.enabled = self.scrollEnabled;
+ }
+
+ return YES;
+}
@end
+5 -2
View File
@@ -39,6 +39,8 @@ import {
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted' import * as persisted from '#/state/persisted'
import {Splash} from '#/Splash' import {Splash} from '#/Splash'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
SplashScreen.preventAutoHideAsync() SplashScreen.preventAutoHideAsync()
@@ -46,17 +48,18 @@ function InnerApp() {
const colorMode = useColorMode() const colorMode = useColorMode()
const {isInitialLoad, currentAccount} = useSession() const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi() const {resumeSession} = useSessionApi()
const {_} = useLingui()
// init // init
useEffect(() => { useEffect(() => {
notifications.init(queryClient) notifications.init(queryClient)
listenSessionDropped(() => { listenSessionDropped(() => {
Toast.show('Sorry! Your session expired. Please log in again.') Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
}) })
const account = persisted.get('session').currentAccount const account = persisted.get('session').currentAccount
resumeSession(account) resumeSession(account)
}, [resumeSession]) }, [resumeSession, _])
return ( return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}> <SafeAreaProvider initialMetrics={initialWindowMetrics}>
+5
View File
@@ -7,6 +7,7 @@ import {RootSiblingParent} from 'react-native-root-siblings'
import 'view/icons' import 'view/icons'
import {ThemeProvider as Alf} from '#/alf'
import {init as initPersistedState} from '#/state/persisted' import {init as initPersistedState} from '#/state/persisted'
import {useColorMode} from 'state/shell' import {useColorMode} from 'state/shell'
import {Shell} from 'view/shell/index' import {Shell} from 'view/shell/index'
@@ -28,11 +29,13 @@ import {
} from 'state/session' } from 'state/session'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted' import * as persisted from '#/state/persisted'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
function InnerApp() { function InnerApp() {
const {isInitialLoad, currentAccount} = useSession() const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi() const {resumeSession} = useSessionApi()
const colorMode = useColorMode() const colorMode = useColorMode()
const theme = useColorModeTheme(colorMode)
// init // init
useEffect(() => { useEffect(() => {
@@ -44,6 +47,7 @@ function InnerApp() {
if (isInitialLoad) return null if (isInitialLoad) return null
return ( return (
<Alf theme={theme}>
<React.Fragment <React.Fragment
// Resets the entire tree below when it changes: // Resets the entire tree below when it changes:
key={currentAccount?.did}> key={currentAccount?.did}>
@@ -61,6 +65,7 @@ function InnerApp() {
</UnreadNotifsProvider> </UnreadNotifsProvider>
</LoggedOutViewProvider> </LoggedOutViewProvider>
</React.Fragment> </React.Fragment>
</Alf>
) )
} }
+59 -36
View File
@@ -26,7 +26,7 @@ import {BottomBar} from './view/shell/bottom-bar/BottomBar'
import {buildStateObject} from 'lib/routes/helpers' import {buildStateObject} from 'lib/routes/helpers'
import {State, RouteParams} from 'lib/routes/types' import {State, RouteParams} from 'lib/routes/types'
import {colors} from 'lib/styles' import {colors} from 'lib/styles'
import {isNative} from 'platform/detection' import {isAndroid, isNative} from 'platform/detection'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {router} from './routes' import {router} from './routes'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
@@ -62,7 +62,7 @@ import {ProfileListScreen} from './view/screens/ProfileList'
import {PostThreadScreen} from './view/screens/PostThread' import {PostThreadScreen} from './view/screens/PostThread'
import {PostLikedByScreen} from './view/screens/PostLikedBy' import {PostLikedByScreen} from './view/screens/PostLikedBy'
import {PostRepostedByScreen} from './view/screens/PostRepostedBy' import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
import {DebugScreen} from './view/screens/Debug' import {DebugScreen} from './view/screens/DebugNew'
import {LogScreen} from './view/screens/Log' import {LogScreen} from './view/screens/Log'
import {SupportScreen} from './view/screens/Support' import {SupportScreen} from './view/screens/Support'
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy' import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
@@ -75,7 +75,10 @@ import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
import {SavedFeeds} from 'view/screens/SavedFeeds' import {SavedFeeds} from 'view/screens/SavedFeeds'
import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed' import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
import {PreferencesThreads} from 'view/screens/PreferencesThreads' import {PreferencesThreads} from 'view/screens/PreferencesThreads'
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth' import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
import {msg} from '@lingui/macro'
import {i18n, MessageDescriptor} from '@lingui/core'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>() const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -93,55 +96,56 @@ const Tab = createBottomTabNavigator<BottomTabNavigatorParams>()
* These "common screens" are reused across stacks. * These "common screens" are reused across stacks.
*/ */
function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
const title = (page: string) => bskyTitle(page, unreadCountLabel) const title = (page: MessageDescriptor) =>
bskyTitle(i18n._(page), unreadCountLabel)
return ( return (
<> <>
<Stack.Screen <Stack.Screen
name="NotFound" name="NotFound"
getComponent={() => NotFoundScreen} getComponent={() => NotFoundScreen}
options={{title: title('Not Found')}} options={{title: title(msg`Not Found`)}}
/> />
<Stack.Screen <Stack.Screen
name="Lists" name="Lists"
component={ListsScreen} component={ListsScreen}
options={{title: title('Lists'), requireAuth: true}} options={{title: title(msg`Lists`), requireAuth: true}}
/> />
<Stack.Screen <Stack.Screen
name="Moderation" name="Moderation"
getComponent={() => ModerationScreen} getComponent={() => ModerationScreen}
options={{title: title('Moderation'), requireAuth: true}} options={{title: title(msg`Moderation`), requireAuth: true}}
/> />
<Stack.Screen <Stack.Screen
name="ModerationModlists" name="ModerationModlists"
getComponent={() => ModerationModlistsScreen} getComponent={() => ModerationModlistsScreen}
options={{title: title('Moderation Lists'), requireAuth: true}} options={{title: title(msg`Moderation Lists`), requireAuth: true}}
/> />
<Stack.Screen <Stack.Screen
name="ModerationMutedAccounts" name="ModerationMutedAccounts"
getComponent={() => ModerationMutedAccounts} getComponent={() => ModerationMutedAccounts}
options={{title: title('Muted Accounts'), requireAuth: true}} options={{title: title(msg`Muted Accounts`), requireAuth: true}}
/> />
<Stack.Screen <Stack.Screen
name="ModerationBlockedAccounts" name="ModerationBlockedAccounts"
getComponent={() => ModerationBlockedAccounts} getComponent={() => ModerationBlockedAccounts}
options={{title: title('Blocked Accounts'), requireAuth: true}} options={{title: title(msg`Blocked Accounts`), requireAuth: true}}
/> />
<Stack.Screen <Stack.Screen
name="Settings" name="Settings"
getComponent={() => SettingsScreen} getComponent={() => SettingsScreen}
options={{title: title('Settings'), requireAuth: true}} options={{title: title(msg`Settings`), requireAuth: true}}
/> />
<Stack.Screen <Stack.Screen
name="LanguageSettings" name="LanguageSettings"
getComponent={() => LanguageSettingsScreen} getComponent={() => LanguageSettingsScreen}
options={{title: title('Language Settings'), requireAuth: true}} options={{title: title(msg`Language Settings`), requireAuth: true}}
/> />
<Stack.Screen <Stack.Screen
name="Profile" name="Profile"
getComponent={() => ProfileScreen} getComponent={() => ProfileScreen}
options={({route}) => ({ options={({route}) => ({
title: title(`@${route.params.name}`), title: title(msg`@${route.params.name}`),
animation: 'none', animation: 'none',
})} })}
/> />
@@ -149,100 +153,114 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
name="ProfileFollowers" name="ProfileFollowers"
getComponent={() => ProfileFollowersScreen} getComponent={() => ProfileFollowersScreen}
options={({route}) => ({ options={({route}) => ({
title: title(`People following @${route.params.name}`), title: title(msg`People following @${route.params.name}`),
})} })}
/> />
<Stack.Screen <Stack.Screen
name="ProfileFollows" name="ProfileFollows"
getComponent={() => ProfileFollowsScreen} getComponent={() => ProfileFollowsScreen}
options={({route}) => ({ options={({route}) => ({
title: title(`People followed by @${route.params.name}`), title: title(msg`People followed by @${route.params.name}`),
})} })}
/> />
<Stack.Screen <Stack.Screen
name="ProfileList" name="ProfileList"
getComponent={() => ProfileListScreen} getComponent={() => ProfileListScreen}
options={{title: title('List'), requireAuth: true}} options={{title: title(msg`List`), requireAuth: true}}
/> />
<Stack.Screen <Stack.Screen
name="PostThread" name="PostThread"
getComponent={() => PostThreadScreen} getComponent={() => PostThreadScreen}
options={({route}) => ({title: title(`Post by @${route.params.name}`)})} options={({route}) => ({
title: title(msg`Post by @${route.params.name}`),
})}
/> />
<Stack.Screen <Stack.Screen
name="PostLikedBy" name="PostLikedBy"
getComponent={() => PostLikedByScreen} getComponent={() => PostLikedByScreen}
options={({route}) => ({title: title(`Post by @${route.params.name}`)})} options={({route}) => ({
title: title(msg`Post by @${route.params.name}`),
})}
/> />
<Stack.Screen <Stack.Screen
name="PostRepostedBy" name="PostRepostedBy"
getComponent={() => PostRepostedByScreen} getComponent={() => PostRepostedByScreen}
options={({route}) => ({title: title(`Post by @${route.params.name}`)})} options={({route}) => ({
title: title(msg`Post by @${route.params.name}`),
})}
/> />
<Stack.Screen <Stack.Screen
name="ProfileFeed" name="ProfileFeed"
getComponent={() => ProfileFeedScreen} getComponent={() => ProfileFeedScreen}
options={{title: title('Feed'), requireAuth: true}} options={{title: title(msg`Feed`), requireAuth: true}}
/> />
<Stack.Screen <Stack.Screen
name="ProfileFeedLikedBy" name="ProfileFeedLikedBy"
getComponent={() => ProfileFeedLikedByScreen} getComponent={() => ProfileFeedLikedByScreen}
options={{title: title('Liked by')}} options={{title: title(msg`Liked by`)}}
/> />
<Stack.Screen <Stack.Screen
name="Debug" name="Debug"
getComponent={() => DebugScreen} getComponent={() => DebugScreen}
options={{title: title('Debug'), requireAuth: true}} options={{title: title(msg`Debug`), requireAuth: true}}
/> />
<Stack.Screen <Stack.Screen
name="Log" name="Log"
getComponent={() => LogScreen} getComponent={() => LogScreen}
options={{title: title('Log'), requireAuth: true}} options={{title: title(msg`Log`), requireAuth: true}}
/> />
<Stack.Screen <Stack.Screen
name="Support" name="Support"
getComponent={() => SupportScreen} getComponent={() => SupportScreen}
options={{title: title('Support')}} options={{title: title(msg`Support`)}}
/> />
<Stack.Screen <Stack.Screen
name="PrivacyPolicy" name="PrivacyPolicy"
getComponent={() => PrivacyPolicyScreen} getComponent={() => PrivacyPolicyScreen}
options={{title: title('Privacy Policy')}} options={{title: title(msg`Privacy Policy`)}}
/> />
<Stack.Screen <Stack.Screen
name="TermsOfService" name="TermsOfService"
getComponent={() => TermsOfServiceScreen} getComponent={() => TermsOfServiceScreen}
options={{title: title('Terms of Service')}} options={{title: title(msg`Terms of Service`)}}
/> />
<Stack.Screen <Stack.Screen
name="CommunityGuidelines" name="CommunityGuidelines"
getComponent={() => CommunityGuidelinesScreen} getComponent={() => CommunityGuidelinesScreen}
options={{title: title('Community Guidelines')}} options={{title: title(msg`Community Guidelines`)}}
/> />
<Stack.Screen <Stack.Screen
name="CopyrightPolicy" name="CopyrightPolicy"
getComponent={() => CopyrightPolicyScreen} getComponent={() => CopyrightPolicyScreen}
options={{title: title('Copyright Policy')}} options={{title: title(msg`Copyright Policy`)}}
/> />
<Stack.Screen <Stack.Screen
name="AppPasswords" name="AppPasswords"
getComponent={() => AppPasswords} getComponent={() => AppPasswords}
options={{title: title('App Passwords'), requireAuth: true}} options={{title: title(msg`App Passwords`), requireAuth: true}}
/> />
<Stack.Screen <Stack.Screen
name="SavedFeeds" name="SavedFeeds"
getComponent={() => SavedFeeds} getComponent={() => SavedFeeds}
options={{title: title('Edit My Feeds'), requireAuth: true}} options={{title: title(msg`Edit My Feeds`), requireAuth: true}}
/> />
<Stack.Screen <Stack.Screen
name="PreferencesHomeFeed" name="PreferencesHomeFeed"
getComponent={() => PreferencesHomeFeed} getComponent={() => PreferencesHomeFeed}
options={{title: title('Home Feed Preferences'), requireAuth: true}} options={{title: title(msg`Home Feed Preferences`), requireAuth: true}}
/> />
<Stack.Screen <Stack.Screen
name="PreferencesThreads" name="PreferencesThreads"
getComponent={() => PreferencesThreads} getComponent={() => PreferencesThreads}
options={{title: title('Threads Preferences'), requireAuth: true}} options={{title: title(msg`Threads Preferences`), requireAuth: true}}
/>
<Stack.Screen
name="PreferencesExternalEmbeds"
getComponent={() => PreferencesExternalEmbeds}
options={{
title: title(msg`External Media Preferences`),
requireAuth: true,
}}
/> />
</> </>
) )
@@ -287,6 +305,7 @@ function HomeTabNavigator() {
return ( return (
<HomeTab.Navigator <HomeTab.Navigator
screenOptions={{ screenOptions={{
animation: isAndroid ? 'none' : undefined,
gestureEnabled: true, gestureEnabled: true,
fullScreenGestureEnabled: true, fullScreenGestureEnabled: true,
headerShown: false, headerShown: false,
@@ -308,6 +327,7 @@ function SearchTabNavigator() {
return ( return (
<SearchTab.Navigator <SearchTab.Navigator
screenOptions={{ screenOptions={{
animation: isAndroid ? 'none' : undefined,
gestureEnabled: true, gestureEnabled: true,
fullScreenGestureEnabled: true, fullScreenGestureEnabled: true,
headerShown: false, headerShown: false,
@@ -325,6 +345,7 @@ function FeedsTabNavigator() {
return ( return (
<FeedsTab.Navigator <FeedsTab.Navigator
screenOptions={{ screenOptions={{
animation: isAndroid ? 'none' : undefined,
gestureEnabled: true, gestureEnabled: true,
fullScreenGestureEnabled: true, fullScreenGestureEnabled: true,
headerShown: false, headerShown: false,
@@ -346,6 +367,7 @@ function NotificationsTabNavigator() {
return ( return (
<NotificationsTab.Navigator <NotificationsTab.Navigator
screenOptions={{ screenOptions={{
animation: isAndroid ? 'none' : undefined,
gestureEnabled: true, gestureEnabled: true,
fullScreenGestureEnabled: true, fullScreenGestureEnabled: true,
headerShown: false, headerShown: false,
@@ -367,6 +389,7 @@ function MyProfileTabNavigator() {
return ( return (
<MyProfileTab.Navigator <MyProfileTab.Navigator
screenOptions={{ screenOptions={{
animation: isAndroid ? 'none' : undefined,
gestureEnabled: true, gestureEnabled: true,
fullScreenGestureEnabled: true, fullScreenGestureEnabled: true,
headerShown: false, headerShown: false,
@@ -394,7 +417,7 @@ const FlatNavigator = () => {
const pal = usePalette('default') const pal = usePalette('default')
const numUnread = useUnreadNotifications() const numUnread = useUnreadNotifications()
const screenListeners = useWebScrollRestoration() const screenListeners = useWebScrollRestoration()
const title = (page: string) => bskyTitle(page, numUnread) const title = (page: MessageDescriptor) => bskyTitle(i18n._(page), numUnread)
return ( return (
<Flat.Navigator <Flat.Navigator
@@ -409,22 +432,22 @@ const FlatNavigator = () => {
<Flat.Screen <Flat.Screen
name="Home" name="Home"
getComponent={() => HomeScreen} getComponent={() => HomeScreen}
options={{title: title('Home'), requireAuth: true}} options={{title: title(msg`Home`), requireAuth: true}}
/> />
<Flat.Screen <Flat.Screen
name="Search" name="Search"
getComponent={() => SearchScreen} getComponent={() => SearchScreen}
options={{title: title('Search')}} options={{title: title(msg`Search`)}}
/> />
<Flat.Screen <Flat.Screen
name="Feeds" name="Feeds"
getComponent={() => FeedsScreen} getComponent={() => FeedsScreen}
options={{title: title('Feeds'), requireAuth: true}} options={{title: title(msg`Feeds`), requireAuth: true}}
/> />
<Flat.Screen <Flat.Screen
name="Notifications" name="Notifications"
getComponent={() => NotificationsScreen} getComponent={() => NotificationsScreen}
options={{title: title('Notifications'), requireAuth: true}} options={{title: title(msg`Notifications`), requireAuth: true}}
/> />
{commonScreens(Flat as typeof HomeTab, numUnread)} {commonScreens(Flat as typeof HomeTab, numUnread)}
</Flat.Navigator> </Flat.Navigator>
+107 -21
View File
@@ -1,5 +1,11 @@
import React, {useCallback, useEffect} from 'react' import React, {useCallback, useEffect} from 'react'
import {View, StyleSheet, Image as RNImage} from 'react-native' import {
View,
StyleSheet,
Image as RNImage,
AccessibilityInfo,
useColorScheme,
} from 'react-native'
import * as SplashScreen from 'expo-splash-screen' import * as SplashScreen from 'expo-splash-screen'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import Animated, { import Animated, {
@@ -14,9 +20,18 @@ import MaskedView from '@react-native-masked-view/masked-view'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import Svg, {Path, SvgProps} from 'react-native-svg' import Svg, {Path, SvgProps} from 'react-native-svg'
import {isAndroid} from '#/platform/detection'
import {useColorMode} from 'state/shell'
import {colors} from '#/lib/styles'
// @ts-ignore // @ts-ignore
import splashImagePointer from '../assets/splash.png' import splashImagePointer from '../assets/splash.png'
// @ts-ignore
import darkSplashImagePointer from '../assets/splash-dark.png'
const splashImageUri = RNImage.resolveAssetSource(splashImagePointer).uri const splashImageUri = RNImage.resolveAssetSource(splashImagePointer).uri
const darkSplashImageUri = RNImage.resolveAssetSource(
darkSplashImagePointer,
).uri
export const Logo = React.forwardRef(function LogoImpl(props: SvgProps, ref) { export const Logo = React.forwardRef(function LogoImpl(props: SvgProps, ref) {
const width = 1000 const width = 1000
@@ -27,9 +42,9 @@ export const Logo = React.forwardRef(function LogoImpl(props: SvgProps, ref) {
// @ts-ignore it's fiiiiine // @ts-ignore it's fiiiiine
ref={ref} ref={ref}
viewBox="0 0 64 66" viewBox="0 0 64 66"
style={{width, height}}> style={[{width, height}, props.style]}>
<Path <Path
fill="#fff" fill={props.fill || '#fff'}
d="M13.873 3.77C21.21 9.243 29.103 20.342 32 26.3v15.732c0-.335-.13.043-.41.858-1.512 4.414-7.418 21.642-20.923 7.87-7.111-7.252-3.819-14.503 9.125-16.692-7.405 1.252-15.73-.817-18.014-8.93C1.12 22.804 0 8.431 0 6.488 0-3.237 8.579-.18 13.873 3.77ZM50.127 3.77C42.79 9.243 34.897 20.342 32 26.3v15.732c0-.335.13.043.41.858 1.512 4.414 7.418 21.642 20.923 7.87 7.111-7.252 3.819-14.503-9.125-16.692 7.405 1.252 15.73-.817 18.014-8.93C62.88 22.804 64 8.431 64 6.488 64-3.237 55.422-.18 50.127 3.77Z" d="M13.873 3.77C21.21 9.243 29.103 20.342 32 26.3v15.732c0-.335-.13.043-.41.858-1.512 4.414-7.418 21.642-20.923 7.87-7.111-7.252-3.819-14.503 9.125-16.692-7.405 1.252-15.73-.817-18.014-8.93C1.12 22.804 0 8.431 0 6.488 0-3.237 8.579-.18 13.873 3.77ZM50.127 3.77C42.79 9.243 34.897 20.342 32 26.3v15.732c0-.335.13.043.41.858 1.512 4.414 7.418 21.642 20.923 7.87 7.111-7.252 3.819-14.503-9.125-16.692 7.405 1.252 15.73-.817 18.014-8.93C62.88 22.804 64 8.431 64 6.488 64-3.237 55.422-.18 50.127 3.77Z"
/> />
</Svg> </Svg>
@@ -40,8 +55,6 @@ type Props = {
isReady: boolean isReady: boolean
} }
SplashScreen.preventAutoHideAsync().catch(() => {})
const AnimatedLogo = Animated.createAnimatedComponent(Logo) const AnimatedLogo = Animated.createAnimatedComponent(Logo)
export function Splash(props: React.PropsWithChildren<Props>) { export function Splash(props: React.PropsWithChildren<Props>) {
@@ -52,9 +65,22 @@ export function Splash(props: React.PropsWithChildren<Props>) {
const outroAppOpacity = useSharedValue(0) const outroAppOpacity = useSharedValue(0)
const [isAnimationComplete, setIsAnimationComplete] = React.useState(false) const [isAnimationComplete, setIsAnimationComplete] = React.useState(false)
const [isImageLoaded, setIsImageLoaded] = React.useState(false) const [isImageLoaded, setIsImageLoaded] = React.useState(false)
const isReady = props.isReady && isImageLoaded const [isLayoutReady, setIsLayoutReady] = React.useState(false)
const [reduceMotion, setReduceMotion] = React.useState<boolean | undefined>(
false,
)
const isReady =
props.isReady &&
isImageLoaded &&
isLayoutReady &&
reduceMotion !== undefined
const logoAnimations = useAnimatedStyle(() => { const colorMode = useColorMode()
const colorScheme = useColorScheme()
const themeName = colorMode === 'system' ? colorScheme : colorMode
const isDarkMode = themeName === 'dark'
const logoAnimation = useAnimatedStyle(() => {
return { return {
transform: [ transform: [
{ {
@@ -64,7 +90,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
scale: interpolate( scale: interpolate(
outroLogo.value, outroLogo.value,
[0, 0.08, 1], [0, 0.08, 1],
[1, 0.8, 400], [1, 0.8, 500],
'clamp', 'clamp',
), ),
}, },
@@ -72,6 +98,27 @@ export function Splash(props: React.PropsWithChildren<Props>) {
opacity: interpolate(intro.value, [0, 1], [0, 1], 'clamp'), opacity: interpolate(intro.value, [0, 1], [0, 1], 'clamp'),
} }
}) })
const reducedLogoAnimation = useAnimatedStyle(() => {
return {
transform: [
{
scale: interpolate(intro.value, [0, 1], [0.8, 1], 'clamp'),
},
],
opacity: interpolate(intro.value, [0, 1], [0, 1], 'clamp'),
}
})
const logoWrapperAnimation = useAnimatedStyle(() => {
return {
opacity: interpolate(
outroAppOpacity.value,
[0, 0.1, 0.2, 1],
[1, 1, 0, 0],
'clamp',
),
}
})
const appAnimation = useAnimatedStyle(() => { const appAnimation = useAnimatedStyle(() => {
return { return {
@@ -82,7 +129,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
], ],
opacity: interpolate( opacity: interpolate(
outroAppOpacity.value, outroAppOpacity.value,
[0, 0.08, 0.15, 1], [0, 0.1, 0.2, 1],
[0, 0, 1, 1], [0, 0, 1, 1],
'clamp', 'clamp',
), ),
@@ -90,12 +137,13 @@ export function Splash(props: React.PropsWithChildren<Props>) {
}) })
const onFinish = useCallback(() => setIsAnimationComplete(true), []) const onFinish = useCallback(() => setIsAnimationComplete(true), [])
const onLayout = useCallback(() => setIsLayoutReady(true), [])
const onLoadEnd = useCallback(() => setIsImageLoaded(true), [])
useEffect(() => { useEffect(() => {
if (isReady) { if (isReady) {
// hide on mount SplashScreen.hideAsync()
SplashScreen.hideAsync().catch(() => {}) .then(() => {
intro.value = withTiming( intro.value = withTiming(
1, 1,
{duration: 400, easing: Easing.out(Easing.cubic)}, {duration: 400, easing: Easing.out(Easing.cubic)},
@@ -120,30 +168,62 @@ export function Splash(props: React.PropsWithChildren<Props>) {
}) })
}, },
) )
})
.catch(() => {})
} }
}, [onFinish, intro, outroLogo, outroApp, outroAppOpacity, isReady]) }, [onFinish, intro, outroLogo, outroApp, outroAppOpacity, isReady])
const onLoadEnd = useCallback(() => { useEffect(() => {
setIsImageLoaded(true) AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion)
}, [setIsImageLoaded]) }, [])
const logoAnimations =
reduceMotion === true ? reducedLogoAnimation : logoAnimation
return ( return (
<View style={{flex: 1}}> <View style={{flex: 1}} onLayout={onLayout}>
{!isAnimationComplete && ( {!isAnimationComplete && (
<Image <Image
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
onLoadEnd={onLoadEnd} onLoadEnd={onLoadEnd}
source={{uri: splashImageUri}} source={{uri: isDarkMode ? darkSplashImageUri : splashImageUri}}
style={StyleSheet.absoluteFillObject} style={StyleSheet.absoluteFillObject}
/> />
)} )}
{isReady &&
(isAndroid || reduceMotion === true ? (
// Use a simple fade on older versions of android (work around a bug)
<>
<Animated.View style={[{flex: 1}, appAnimation]}>
{props.children}
</Animated.View>
{!isAnimationComplete && (
<Animated.View
style={[
StyleSheet.absoluteFillObject,
logoWrapperAnimation,
{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px
},
]}>
<AnimatedLogo
fill={isDarkMode ? colors.blue3 : '#fff'}
style={[{opacity: 0}, logoAnimations]}
/>
</Animated.View>
)}
</>
) : (
<MaskedView <MaskedView
style={[StyleSheet.absoluteFillObject]} style={[StyleSheet.absoluteFillObject]}
maskElement={ maskElement={
<Animated.View <Animated.View
style={[ style={[
StyleSheet.absoluteFillObject,
{ {
// Transparent background because mask is based off alpha channel. // Transparent background because mask is based off alpha channel.
backgroundColor: 'transparent', backgroundColor: 'transparent',
@@ -153,19 +233,25 @@ export function Splash(props: React.PropsWithChildren<Props>) {
transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px
}, },
]}> ]}>
<AnimatedLogo style={[logoAnimations]} /> <AnimatedLogo
fill={isDarkMode ? colors.blue3 : '#fff'}
style={[logoAnimations]}
/>
</Animated.View> </Animated.View>
}> }>
{!isAnimationComplete && ( {!isAnimationComplete && (
<View <View
style={[StyleSheet.absoluteFillObject, {backgroundColor: 'white'}]} style={[
StyleSheet.absoluteFillObject,
{backgroundColor: isDarkMode ? colors.blue3 : '#fff'},
]}
/> />
)} )}
<Animated.View style={[{flex: 1}, appAnimation]}> <Animated.View style={[{flex: 1}, appAnimation]}>
{props.children} {props.children}
</Animated.View> </Animated.View>
</MaskedView> </MaskedView>
))}
</View> </View>
) )
} }
+56
View File
@@ -0,0 +1,56 @@
# Application Layout Framework (ALF)
A set of UI primitives and components.
## Usage
Naming conventions follow Tailwind — delimited with a `_` instead of `-` to
enable object access — with a couple exceptions:
**Spacing**
Uses "t-shirt" sizes `xxs`, `xs`, `sm`, `md`, `lg`, `xl` and `xxl` instead of
increments of 4px. We only use a few common spacings, and otherwise typically
rely on many one-off values.
**Text Size**
Uses "t-shirt" sizes `xxs`, `xs`, `sm`, `md`, `lg`, `xl` and `xxl` to match our
type scale.
**Line Height**
The text size atoms also apply a line-height with the same value as the size,
for a 1:1 ratio. `tight` and `normal` are retained for use in the few places
where we need leading.
### Atoms
An (mostly-complete) set of style definitions that match Tailwind CSS selectors.
These are static and reused throughout the app.
```tsx
import { atoms } from '#/alf'
<View style={[atoms.flex_row]} />
```
### Theme
Any values that rely on the theme, namely colors.
```tsx
const t = useTheme()
<View style={[atoms.flex_row, t.atoms.bg]} />
```
### Breakpoints
```tsx
const b = useBreakpoints()
if (b.gtMobile) {
// render tablet or desktop UI
}
```
+514
View File
@@ -0,0 +1,514 @@
import * as tokens from '#/alf/tokens'
export const atoms = {
/*
* Positioning
*/
absolute: {
position: 'absolute',
},
relative: {
position: 'relative',
},
inset_0: {
top: 0,
left: 0,
right: 0,
bottom: 0,
},
z_10: {
zIndex: 10,
},
z_20: {
zIndex: 20,
},
z_30: {
zIndex: 30,
},
z_40: {
zIndex: 40,
},
z_50: {
zIndex: 50,
},
/*
* Width
*/
w_full: {
width: '100%',
},
h_full: {
height: '100%',
},
/*
* Border radius
*/
rounded_sm: {
borderRadius: tokens.borderRadius.sm,
},
rounded_md: {
borderRadius: tokens.borderRadius.md,
},
rounded_full: {
borderRadius: tokens.borderRadius.full,
},
/*
* Flex
*/
gap_xxs: {
gap: tokens.space.xxs,
},
gap_xs: {
gap: tokens.space.xs,
},
gap_sm: {
gap: tokens.space.sm,
},
gap_md: {
gap: tokens.space.md,
},
gap_lg: {
gap: tokens.space.lg,
},
gap_xl: {
gap: tokens.space.xl,
},
gap_xxl: {
gap: tokens.space.xxl,
},
flex: {
display: 'flex',
},
flex_row: {
flexDirection: 'row',
},
flex_wrap: {
flexWrap: 'wrap',
},
flex_1: {
flex: 1,
},
flex_grow: {
flexGrow: 1,
},
flex_shrink: {
flexShrink: 1,
},
justify_center: {
justifyContent: 'center',
},
justify_between: {
justifyContent: 'space-between',
},
justify_end: {
justifyContent: 'flex-end',
},
align_center: {
alignItems: 'center',
},
align_start: {
alignItems: 'flex-start',
},
align_end: {
alignItems: 'flex-end',
},
/*
* Text
*/
text_center: {
textAlign: 'center',
},
text_right: {
textAlign: 'right',
},
text_xxs: {
fontSize: tokens.fontSize.xxs,
lineHeight: tokens.fontSize.xxs,
},
text_xs: {
fontSize: tokens.fontSize.xs,
lineHeight: tokens.fontSize.xs,
},
text_sm: {
fontSize: tokens.fontSize.sm,
lineHeight: tokens.fontSize.sm,
},
text_md: {
fontSize: tokens.fontSize.md,
lineHeight: tokens.fontSize.md,
},
text_lg: {
fontSize: tokens.fontSize.lg,
lineHeight: tokens.fontSize.lg,
},
text_xl: {
fontSize: tokens.fontSize.xl,
lineHeight: tokens.fontSize.xl,
},
text_xxl: {
fontSize: tokens.fontSize.xxl,
lineHeight: tokens.fontSize.xxl,
},
leading_tight: {
lineHeight: 1.25,
},
leading_normal: {
lineHeight: 1.5,
},
font_normal: {
fontWeight: tokens.fontWeight.normal,
},
font_semibold: {
fontWeight: tokens.fontWeight.semibold,
},
font_bold: {
fontWeight: tokens.fontWeight.bold,
},
/*
* Border
*/
border: {
borderWidth: 1,
},
border_t: {
borderTopWidth: 1,
},
border_b: {
borderBottomWidth: 1,
},
/*
* Padding
*/
p_xxs: {
padding: tokens.space.xxs,
},
p_xs: {
padding: tokens.space.xs,
},
p_sm: {
padding: tokens.space.sm,
},
p_md: {
padding: tokens.space.md,
},
p_lg: {
padding: tokens.space.lg,
},
p_xl: {
padding: tokens.space.xl,
},
p_xxl: {
padding: tokens.space.xxl,
},
px_xxs: {
paddingLeft: tokens.space.xxs,
paddingRight: tokens.space.xxs,
},
px_xs: {
paddingLeft: tokens.space.xs,
paddingRight: tokens.space.xs,
},
px_sm: {
paddingLeft: tokens.space.sm,
paddingRight: tokens.space.sm,
},
px_md: {
paddingLeft: tokens.space.md,
paddingRight: tokens.space.md,
},
px_lg: {
paddingLeft: tokens.space.lg,
paddingRight: tokens.space.lg,
},
px_xl: {
paddingLeft: tokens.space.xl,
paddingRight: tokens.space.xl,
},
px_xxl: {
paddingLeft: tokens.space.xxl,
paddingRight: tokens.space.xxl,
},
py_xxs: {
paddingTop: tokens.space.xxs,
paddingBottom: tokens.space.xxs,
},
py_xs: {
paddingTop: tokens.space.xs,
paddingBottom: tokens.space.xs,
},
py_sm: {
paddingTop: tokens.space.sm,
paddingBottom: tokens.space.sm,
},
py_md: {
paddingTop: tokens.space.md,
paddingBottom: tokens.space.md,
},
py_lg: {
paddingTop: tokens.space.lg,
paddingBottom: tokens.space.lg,
},
py_xl: {
paddingTop: tokens.space.xl,
paddingBottom: tokens.space.xl,
},
py_xxl: {
paddingTop: tokens.space.xxl,
paddingBottom: tokens.space.xxl,
},
pt_xxs: {
paddingTop: tokens.space.xxs,
},
pt_xs: {
paddingTop: tokens.space.xs,
},
pt_sm: {
paddingTop: tokens.space.sm,
},
pt_md: {
paddingTop: tokens.space.md,
},
pt_lg: {
paddingTop: tokens.space.lg,
},
pt_xl: {
paddingTop: tokens.space.xl,
},
pt_xxl: {
paddingTop: tokens.space.xxl,
},
pb_xxs: {
paddingBottom: tokens.space.xxs,
},
pb_xs: {
paddingBottom: tokens.space.xs,
},
pb_sm: {
paddingBottom: tokens.space.sm,
},
pb_md: {
paddingBottom: tokens.space.md,
},
pb_lg: {
paddingBottom: tokens.space.lg,
},
pb_xl: {
paddingBottom: tokens.space.xl,
},
pb_xxl: {
paddingBottom: tokens.space.xxl,
},
pl_xxs: {
paddingLeft: tokens.space.xxs,
},
pl_xs: {
paddingLeft: tokens.space.xs,
},
pl_sm: {
paddingLeft: tokens.space.sm,
},
pl_md: {
paddingLeft: tokens.space.md,
},
pl_lg: {
paddingLeft: tokens.space.lg,
},
pl_xl: {
paddingLeft: tokens.space.xl,
},
pl_xxl: {
paddingLeft: tokens.space.xxl,
},
pr_xxs: {
paddingRight: tokens.space.xxs,
},
pr_xs: {
paddingRight: tokens.space.xs,
},
pr_sm: {
paddingRight: tokens.space.sm,
},
pr_md: {
paddingRight: tokens.space.md,
},
pr_lg: {
paddingRight: tokens.space.lg,
},
pr_xl: {
paddingRight: tokens.space.xl,
},
pr_xxl: {
paddingRight: tokens.space.xxl,
},
/*
* Margin
*/
m_xxs: {
margin: tokens.space.xxs,
},
m_xs: {
margin: tokens.space.xs,
},
m_sm: {
margin: tokens.space.sm,
},
m_md: {
margin: tokens.space.md,
},
m_lg: {
margin: tokens.space.lg,
},
m_xl: {
margin: tokens.space.xl,
},
m_xxl: {
margin: tokens.space.xxl,
},
mx_xxs: {
marginLeft: tokens.space.xxs,
marginRight: tokens.space.xxs,
},
mx_xs: {
marginLeft: tokens.space.xs,
marginRight: tokens.space.xs,
},
mx_sm: {
marginLeft: tokens.space.sm,
marginRight: tokens.space.sm,
},
mx_md: {
marginLeft: tokens.space.md,
marginRight: tokens.space.md,
},
mx_lg: {
marginLeft: tokens.space.lg,
marginRight: tokens.space.lg,
},
mx_xl: {
marginLeft: tokens.space.xl,
marginRight: tokens.space.xl,
},
mx_xxl: {
marginLeft: tokens.space.xxl,
marginRight: tokens.space.xxl,
},
my_xxs: {
marginTop: tokens.space.xxs,
marginBottom: tokens.space.xxs,
},
my_xs: {
marginTop: tokens.space.xs,
marginBottom: tokens.space.xs,
},
my_sm: {
marginTop: tokens.space.sm,
marginBottom: tokens.space.sm,
},
my_md: {
marginTop: tokens.space.md,
marginBottom: tokens.space.md,
},
my_lg: {
marginTop: tokens.space.lg,
marginBottom: tokens.space.lg,
},
my_xl: {
marginTop: tokens.space.xl,
marginBottom: tokens.space.xl,
},
my_xxl: {
marginTop: tokens.space.xxl,
marginBottom: tokens.space.xxl,
},
mt_xxs: {
marginTop: tokens.space.xxs,
},
mt_xs: {
marginTop: tokens.space.xs,
},
mt_sm: {
marginTop: tokens.space.sm,
},
mt_md: {
marginTop: tokens.space.md,
},
mt_lg: {
marginTop: tokens.space.lg,
},
mt_xl: {
marginTop: tokens.space.xl,
},
mt_xxl: {
marginTop: tokens.space.xxl,
},
mb_xxs: {
marginBottom: tokens.space.xxs,
},
mb_xs: {
marginBottom: tokens.space.xs,
},
mb_sm: {
marginBottom: tokens.space.sm,
},
mb_md: {
marginBottom: tokens.space.md,
},
mb_lg: {
marginBottom: tokens.space.lg,
},
mb_xl: {
marginBottom: tokens.space.xl,
},
mb_xxl: {
marginBottom: tokens.space.xxl,
},
ml_xxs: {
marginLeft: tokens.space.xxs,
},
ml_xs: {
marginLeft: tokens.space.xs,
},
ml_sm: {
marginLeft: tokens.space.sm,
},
ml_md: {
marginLeft: tokens.space.md,
},
ml_lg: {
marginLeft: tokens.space.lg,
},
ml_xl: {
marginLeft: tokens.space.xl,
},
ml_xxl: {
marginLeft: tokens.space.xxl,
},
mr_xxs: {
marginRight: tokens.space.xxs,
},
mr_xs: {
marginRight: tokens.space.xs,
},
mr_sm: {
marginRight: tokens.space.sm,
},
mr_md: {
marginRight: tokens.space.md,
},
mr_lg: {
marginRight: tokens.space.lg,
},
mr_xl: {
marginRight: tokens.space.xl,
},
mr_xxl: {
marginRight: tokens.space.xxl,
},
} as const
+92
View File
@@ -0,0 +1,92 @@
import React from 'react'
import {Dimensions} from 'react-native'
import * as themes from '#/alf/themes'
export * as tokens from '#/alf/tokens'
export {atoms} from '#/alf/atoms'
export * from '#/alf/util/platform'
type BreakpointName = keyof typeof breakpoints
/*
* Breakpoints
*/
const breakpoints: {
[key: string]: number
} = {
gtMobile: 800,
gtTablet: 1200,
}
function getActiveBreakpoints({width}: {width: number}) {
const active: (keyof typeof breakpoints)[] = Object.keys(breakpoints).filter(
breakpoint => width >= breakpoints[breakpoint],
)
return {
active: active[active.length - 1],
gtMobile: active.includes('gtMobile'),
gtTablet: active.includes('gtTablet'),
}
}
/*
* Context
*/
export const Context = React.createContext<{
themeName: themes.ThemeName
theme: themes.Theme
breakpoints: {
active: BreakpointName | undefined
gtMobile: boolean
gtTablet: boolean
}
}>({
themeName: 'light',
theme: themes.light,
breakpoints: {
active: undefined,
gtMobile: false,
gtTablet: false,
},
})
export function ThemeProvider({
children,
theme: themeName,
}: React.PropsWithChildren<{theme: themes.ThemeName}>) {
const theme = themes[themeName]
const [breakpoints, setBreakpoints] = React.useState(() =>
getActiveBreakpoints({width: Dimensions.get('window').width}),
)
React.useEffect(() => {
const listener = Dimensions.addEventListener('change', ({window}) => {
const bp = getActiveBreakpoints({width: window.width})
if (bp.active !== breakpoints.active) setBreakpoints(bp)
})
return listener.remove
}, [breakpoints, setBreakpoints])
return (
<Context.Provider
value={React.useMemo(
() => ({
themeName: themeName,
theme: theme,
breakpoints,
}),
[theme, themeName, breakpoints],
)}>
{children}
</Context.Provider>
)
}
export function useTheme() {
return React.useContext(Context).theme
}
export function useBreakpoints() {
return React.useContext(Context).breakpoints
}
+108
View File
@@ -0,0 +1,108 @@
import * as tokens from '#/alf/tokens'
import type {Mutable} from '#/alf/types'
export type ThemeName = 'light' | 'dark'
export type ReadonlyTheme = typeof light
export type Theme = Mutable<ReadonlyTheme>
export type Palette = {
primary: string
positive: string
negative: string
}
export const lightPalette: Palette = {
primary: tokens.color.blue_500,
positive: tokens.color.green_500,
negative: tokens.color.red_500,
} as const
export const darkPalette: Palette = {
primary: tokens.color.blue_500,
positive: tokens.color.green_400,
negative: tokens.color.red_400,
} as const
export const light = {
palette: lightPalette,
atoms: {
text: {
color: tokens.color.gray_1000,
},
text_contrast_700: {
color: tokens.color.gray_700,
},
text_contrast_500: {
color: tokens.color.gray_500,
},
text_inverted: {
color: tokens.color.white,
},
bg: {
backgroundColor: tokens.color.white,
},
bg_contrast_100: {
backgroundColor: tokens.color.gray_100,
},
bg_contrast_200: {
backgroundColor: tokens.color.gray_200,
},
bg_contrast_300: {
backgroundColor: tokens.color.gray_300,
},
bg_positive: {
backgroundColor: tokens.color.green_500,
},
bg_negative: {
backgroundColor: tokens.color.red_400,
},
border: {
borderColor: tokens.color.gray_200,
},
border_contrast_500: {
borderColor: tokens.color.gray_500,
},
},
}
export const dark: Theme = {
palette: darkPalette,
atoms: {
text: {
color: tokens.color.white,
},
text_contrast_700: {
color: tokens.color.gray_300,
},
text_contrast_500: {
color: tokens.color.gray_500,
},
text_inverted: {
color: tokens.color.gray_1000,
},
bg: {
backgroundColor: tokens.color.gray_1000,
},
bg_contrast_100: {
backgroundColor: tokens.color.gray_900,
},
bg_contrast_200: {
backgroundColor: tokens.color.gray_800,
},
bg_contrast_300: {
backgroundColor: tokens.color.gray_700,
},
bg_positive: {
backgroundColor: tokens.color.green_400,
},
bg_negative: {
backgroundColor: tokens.color.red_400,
},
border: {
borderColor: tokens.color.gray_800,
},
border_contrast_500: {
borderColor: tokens.color.gray_500,
},
},
}
+100
View File
@@ -0,0 +1,100 @@
const BLUE_HUE = 211
const GRAYSCALE_SATURATION = 22
export const color = {
white: '#FFFFFF',
gray_0: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 100%)`,
gray_100: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 95%)`,
gray_200: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 85%)`,
gray_300: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 75%)`,
gray_400: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 65%)`,
gray_500: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 55%)`,
gray_600: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 45%)`,
gray_700: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 35%)`,
gray_800: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 25%)`,
gray_900: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 15%)`,
gray_1000: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 5%)`,
blue_0: `hsl(${BLUE_HUE}, 99%, 100%)`,
blue_100: `hsl(${BLUE_HUE}, 99%, 93%)`,
blue_200: `hsl(${BLUE_HUE}, 99%, 83%)`,
blue_300: `hsl(${BLUE_HUE}, 99%, 73%)`,
blue_400: `hsl(${BLUE_HUE}, 99%, 63%)`,
blue_500: `hsl(${BLUE_HUE}, 99%, 53%)`,
blue_600: `hsl(${BLUE_HUE}, 99%, 43%)`,
blue_700: `hsl(${BLUE_HUE}, 99%, 33%)`,
blue_800: `hsl(${BLUE_HUE}, 99%, 23%)`,
blue_900: `hsl(${BLUE_HUE}, 99%, 13%)`,
blue_1000: `hsl(${BLUE_HUE}, 99%, 8%)`,
green_0: `hsl(130, 60%, 100%)`,
green_100: `hsl(130, 60%, 95%)`,
green_200: `hsl(130, 60%, 85%)`,
green_300: `hsl(130, 60%, 75%)`,
green_400: `hsl(130, 60%, 65%)`,
green_500: `hsl(130, 60%, 55%)`,
green_600: `hsl(130, 60%, 45%)`,
green_700: `hsl(130, 60%, 35%)`,
green_800: `hsl(130, 60%, 25%)`,
green_900: `hsl(130, 60%, 15%)`,
green_1000: `hsl(130, 60%, 5%)`,
red_0: `hsl(349, 96%, 100%)`,
red_100: `hsl(349, 96%, 95%)`,
red_200: `hsl(349, 96%, 85%)`,
red_300: `hsl(349, 96%, 75%)`,
red_400: `hsl(349, 96%, 65%)`,
red_500: `hsl(349, 96%, 55%)`,
red_600: `hsl(349, 96%, 45%)`,
red_700: `hsl(349, 96%, 35%)`,
red_800: `hsl(349, 96%, 25%)`,
red_900: `hsl(349, 96%, 15%)`,
red_1000: `hsl(349, 96%, 5%)`,
} as const
export const space = {
xxs: 2,
xs: 4,
sm: 8,
md: 12,
lg: 18,
xl: 24,
xxl: 32,
} as const
export const fontSize = {
xxs: 10,
xs: 12,
sm: 14,
md: 16,
lg: 18,
xl: 22,
xxl: 26,
} as const
// TODO test
export const lineHeight = {
none: 1,
normal: 1.5,
relaxed: 1.625,
} as const
export const borderRadius = {
sm: 8,
md: 12,
full: 999,
} as const
export const fontWeight = {
normal: '400',
semibold: '600',
bold: '900',
} as const
export type Color = keyof typeof color
export type Space = keyof typeof space
export type FontSize = keyof typeof fontSize
export type LineHeight = keyof typeof lineHeight
export type BorderRadius = keyof typeof borderRadius
export type FontWeight = keyof typeof fontWeight
+16
View File
@@ -0,0 +1,16 @@
type LiteralToCommon<T extends PropertyKey> = T extends number
? number
: T extends string
? string
: T extends symbol
? symbol
: never
/**
* @see https://stackoverflow.com/questions/68249999/use-as-const-in-typescript-without-adding-readonly-modifiers
*/
export type Mutable<T> = {
-readonly [K in keyof T]: T[K] extends PropertyKey
? LiteralToCommon<T[K]>
: Mutable<T[K]>
}
+25
View File
@@ -0,0 +1,25 @@
import {Platform} from 'react-native'
export function web(value: any) {
return Platform.select({
web: value,
})
}
export function ios(value: any) {
return Platform.select({
ios: value,
})
}
export function android(value: any) {
return Platform.select({
android: value,
})
}
export function native(value: any) {
return Platform.select({
native: value,
})
}
+10
View File
@@ -0,0 +1,10 @@
import {useColorScheme} from 'react-native'
import * as persisted from '#/state/persisted'
export function useColorModeTheme(
theme: persisted.Schema['colorMode'],
): 'light' | 'dark' {
const colorScheme = useColorScheme()
return (theme === 'system' ? colorScheme : theme) || 'light'
}
+5 -18
View File
@@ -1,28 +1,17 @@
import React, {createContext, useContext, useMemo} from 'react' import React, {createContext, useContext, useMemo} from 'react'
import {ScrollHandler} from 'react-native-reanimated' import {ScrollHandlers} from 'react-native-reanimated'
import {NativeScrollEvent} from 'react-native'
type ScrollHandlers = { const ScrollContext = createContext<ScrollHandlers<any>>({
onBeginDrag: undefined | ScrollHandler
onEndDrag: undefined | ScrollHandler<any>
onScroll: undefined | ScrollHandler<any>
onScrollEndWeb:
| undefined
| ((e: Pick<NativeScrollEvent, 'contentOffset'>) => void) // Web-only.
}
const ScrollContext = createContext<ScrollHandlers>({
onBeginDrag: undefined, onBeginDrag: undefined,
onEndDrag: undefined, onEndDrag: undefined,
onScroll: undefined, onScroll: undefined,
onScrollEndWeb: undefined,
}) })
export function useScrollHandlers(): ScrollHandlers { export function useScrollHandlers(): ScrollHandlers<any> {
return useContext(ScrollContext) return useContext(ScrollContext)
} }
type ProviderProps = {children: React.ReactNode} & Partial<ScrollHandlers> type ProviderProps = {children: React.ReactNode} & ScrollHandlers<any>
// Note: this completely *overrides* the parent handlers. // Note: this completely *overrides* the parent handlers.
// It's up to you to compose them with the parent ones via useScrollHandlers() if needed. // It's up to you to compose them with the parent ones via useScrollHandlers() if needed.
@@ -31,16 +20,14 @@ export function ScrollProvider({
onBeginDrag, onBeginDrag,
onEndDrag, onEndDrag,
onScroll, onScroll,
onScrollEndWeb,
}: ProviderProps) { }: ProviderProps) {
const handlers = useMemo( const handlers = useMemo(
() => ({ () => ({
onBeginDrag, onBeginDrag,
onEndDrag, onEndDrag,
onScroll, onScroll,
onScrollEndWeb,
}), }),
[onBeginDrag, onEndDrag, onScroll, onScrollEndWeb], [onBeginDrag, onEndDrag, onScroll],
) )
return ( return (
<ScrollContext.Provider value={handlers}>{children}</ScrollContext.Provider> <ScrollContext.Provider value={handlers}>{children}</ScrollContext.Provider>
+2 -30
View File
@@ -1,9 +1,7 @@
import {isWeb} from 'platform/detection'
import React, {ReactNode, createContext, useContext} from 'react' import React, {ReactNode, createContext, useContext} from 'react'
import { import {
AppState,
TextStyle, TextStyle,
useColorScheme as useColorScheme_BUGGY, useColorScheme,
ViewStyle, ViewStyle,
ColorSchemeName, ColorSchemeName,
} from 'react-native' } from 'react-native'
@@ -97,37 +95,11 @@ function getTheme(theme: ColorSchemeName) {
return theme === 'dark' ? darkTheme : defaultTheme return theme === 'dark' ? darkTheme : defaultTheme
} }
/**
* With RN iOS, we can only "trust" the color scheme reported while the app is
* active. This is a workaround until the bug is fixed upstream.
*
* @see https://github.com/bluesky-social/social-app/pull/1417#issuecomment-1719868504
* @see https://github.com/facebook/react-native/pull/39439
*/
function useColorScheme_FIXED() {
const colorScheme = useColorScheme_BUGGY()
const [currentColorScheme, setCurrentColorScheme] =
React.useState<ColorSchemeName>(colorScheme)
React.useEffect(() => {
// we don't need to be updating state on web
if (isWeb) return
const subscription = AppState.addEventListener('change', state => {
const isActive = state === 'active'
if (!isActive) return
setCurrentColorScheme(colorScheme)
})
return () => subscription.remove()
}, [colorScheme])
return isWeb ? colorScheme : currentColorScheme
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
theme, theme,
children, children,
}) => { }) => {
const colorScheme = useColorScheme_FIXED() const colorScheme = useColorScheme()
const themeValue = getTheme(theme === 'system' ? colorScheme : theme) const themeValue = getTheme(theme === 'system' ? colorScheme : theme)
return ( return (
+1
View File
@@ -147,6 +147,7 @@ interface ScreenPropertiesMap {
Settings: {} Settings: {}
AppPasswords: {} AppPasswords: {}
Moderation: {} Moderation: {}
PreferencesExternalEmbeds: {}
BlockedAccounts: {} BlockedAccounts: {}
MutedAccounts: {} MutedAccounts: {}
SavedFeeds: {} SavedFeeds: {}
+10 -9
View File
@@ -117,11 +117,7 @@ export class FeedViewPostsSlice {
} }
export class NoopFeedTuner { export class NoopFeedTuner {
private keyCounter = 0 reset() {}
reset() {
this.keyCounter = 0
}
tune( tune(
feed: FeedViewPost[], feed: FeedViewPost[],
_opts?: {dryRun: boolean; maintainOrder: boolean}, _opts?: {dryRun: boolean; maintainOrder: boolean},
@@ -131,13 +127,13 @@ export class NoopFeedTuner {
} }
export class FeedTuner { export class FeedTuner {
private keyCounter = 0 seenKeys: Set<string> = new Set()
seenUris: Set<string> = new Set() seenUris: Set<string> = new Set()
constructor(public tunerFns: FeedTunerFn[]) {} constructor(public tunerFns: FeedTunerFn[]) {}
reset() { reset() {
this.keyCounter = 0 this.seenKeys.clear()
this.seenUris.clear() this.seenUris.clear()
} }
@@ -218,11 +214,16 @@ export class FeedTuner {
} }
if (!dryRun) { if (!dryRun) {
for (const slice of slices) { slices = slices.filter(slice => {
if (this.seenKeys.has(slice._reactKey)) {
return false
}
for (const item of slice.items) { for (const item of slice.items) {
this.seenUris.add(item.post.uri) this.seenUris.add(item.post.uri)
} }
} this.seenKeys.add(slice._reactKey)
return true
})
} }
return slices return slices
+1 -1
View File
@@ -98,7 +98,7 @@ export class MergeFeedAPI implements FeedAPI {
} }
return { return {
cursor: posts.length ? String(this.itemCursor) : undefined, cursor: String(this.itemCursor),
feed: posts, feed: posts,
} }
} }
-69
View File
@@ -1,69 +0,0 @@
/**
* This is a temporary off-spec search endpoint
* TODO removeme when we land this in proto!
*/
import {AppBskyFeedPost} from '@atproto/api'
const PROFILES_ENDPOINT = 'https://search.bsky.social/search/profiles'
const POSTS_ENDPOINT = 'https://search.bsky.social/search/posts'
export interface ProfileSearchItem {
$type: string
avatar: {
cid: string
mimeType: string
}
banner: {
cid: string
mimeType: string
}
description: string | undefined
displayName: string | undefined
did: string
}
export interface PostSearchItem {
tid: string
cid: string
user: {
did: string
handle: string
}
post: AppBskyFeedPost.Record
}
export async function searchProfiles(
query: string,
): Promise<ProfileSearchItem[]> {
return await doFetch<ProfileSearchItem[]>(PROFILES_ENDPOINT, query)
}
export async function searchPosts(query: string): Promise<PostSearchItem[]> {
return await doFetch<PostSearchItem[]>(POSTS_ENDPOINT, query)
}
async function doFetch<T>(endpoint: string, query: string): Promise<T> {
const controller = new AbortController()
const to = setTimeout(() => controller.abort(), 15e3)
const uri = new URL(endpoint)
uri.searchParams.set('q', query)
const res = await fetch(String(uri), {
method: 'get',
headers: {
accept: 'application/json',
},
signal: controller.signal,
})
const resHeaders: Record<string, string> = {}
res.headers.forEach((value: string, key: string) => {
resHeaders[key] = value
})
let resBody = await res.json()
clearTimeout(to)
return resBody as unknown as T
}
+5 -4
View File
@@ -41,7 +41,7 @@ export function IS_LOCAL_DEV(url: string) {
} }
export function IS_STAGING(url: string) { export function IS_STAGING(url: string) {
return !IS_LOCAL_DEV(url) && !IS_PROD(url) return url.startsWith('https://staging.bsky.dev')
} }
export function IS_PROD(url: string) { export function IS_PROD(url: string) {
@@ -51,7 +51,8 @@ export function IS_PROD(url: string) {
// -prf // -prf
return ( return (
url.startsWith('https://bsky.social') || url.startsWith('https://bsky.social') ||
url.startsWith('https://api.bsky.app') url.startsWith('https://api.bsky.app') ||
/bsky\.network\/?$/.test(url)
) )
} }
@@ -116,8 +117,8 @@ export async function DEFAULT_FEEDS(
} else { } else {
// production // production
return { return {
pinned: [], pinned: [PROD_DEFAULT_FEED('whats-hot')],
saved: [], saved: [PROD_DEFAULT_FEED('whats-hot')],
} }
} }
} }
+16
View File
@@ -0,0 +1,16 @@
export function usePhotoLibraryPermission() {
const requestPhotoAccessIfNeeded = async () => {
// On the, we use <input type="file"> to produce a filepicker
// This does not need any permission granting.
return true
}
return {requestPhotoAccessIfNeeded}
}
export function useCameraPermission() {
const requestCameraAccessIfNeeded = async () => {
return false
}
return {requestCameraAccessIfNeeded}
}
+8
View File
@@ -2,6 +2,7 @@ import {BskyAgent} from '@atproto/api'
import {isBskyAppUrl} from '../strings/url-helpers' import {isBskyAppUrl} from '../strings/url-helpers'
import {extractBskyMeta} from './bsky' import {extractBskyMeta} from './bsky'
import {LINK_META_PROXY} from 'lib/constants' import {LINK_META_PROXY} from 'lib/constants'
import {getGiphyMetaUri} from 'lib/strings/embed-player'
export enum LikelyType { export enum LikelyType {
HTML, HTML,
@@ -34,6 +35,13 @@ export async function getLinkMeta(
let urlp let urlp
try { try {
urlp = new URL(url) urlp = new URL(url)
// Get Giphy meta uri if this is any form of giphy link
const giphyMetaUri = getGiphyMetaUri(urlp)
if (giphyMetaUri) {
url = giphyMetaUri
urlp = new URL(url)
}
} catch (e) { } catch (e) {
return { return {
error: 'Invalid URL', error: 'Invalid URL',
+4 -6
View File
@@ -117,9 +117,6 @@ function createResizedImage(
return reject(new Error('Failed to resize image')) return reject(new Error('Failed to resize image'))
} }
canvas.width = width
canvas.height = height
let scale = 1 let scale = 1
if (mode === 'cover') { if (mode === 'cover') {
scale = img.width < img.height ? width / img.width : height / img.height scale = img.width < img.height ? width / img.width : height / img.height
@@ -128,10 +125,11 @@ function createResizedImage(
} }
let w = img.width * scale let w = img.width * scale
let h = img.height * scale let h = img.height * scale
let x = (width - w) / 2
let y = (height - h) / 2
ctx.drawImage(img, x, y, w, h) canvas.width = w
canvas.height = h
ctx.drawImage(img, 0, 0, w, h)
resolve(canvas.toDataURL('image/jpeg', quality)) resolve(canvas.toDataURL('image/jpeg', quality))
}) })
img.src = dataUri img.src = dataUri
+6 -1
View File
@@ -4,6 +4,7 @@ import {
MediaTypeOptions, MediaTypeOptions,
} from 'expo-image-picker' } from 'expo-image-picker'
import {getDataUriSize} from './util' import {getDataUriSize} from './util'
import * as Toast from 'view/com/util/Toast'
export async function openPicker(opts?: ImagePickerOptions) { export async function openPicker(opts?: ImagePickerOptions) {
const response = await launchImageLibraryAsync({ const response = await launchImageLibraryAsync({
@@ -13,7 +14,11 @@ export async function openPicker(opts?: ImagePickerOptions) {
...opts, ...opts,
}) })
return (response.assets ?? []).map(image => ({ if (response.assets && response.assets.length > 4) {
Toast.show('You may only select up to 4 images')
}
return (response.assets ?? []).slice(0, 4).map(image => ({
mime: 'image/jpeg', mime: 'image/jpeg',
height: image.height, height: image.height,
width: image.width, width: image.width,
+58
View File
@@ -0,0 +1,58 @@
import {
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
moderatePost,
} from '@atproto/api'
type ModeratePost = typeof moderatePost
type Options = Parameters<ModeratePost>[1] & {
hiddenPosts?: string[]
}
export function moderatePost_wrapped(
subject: Parameters<ModeratePost>[0],
opts: Options,
) {
const {hiddenPosts = [], ...options} = opts
const moderations = moderatePost(subject, options)
if (hiddenPosts.includes(subject.uri)) {
moderations.content.filter = true
moderations.content.blur = true
if (!moderations.content.cause) {
moderations.content.cause = {
// @ts-ignore Temporary extension to the moderation system -prf
type: 'post-hidden',
source: {type: 'user'},
priority: 1,
}
}
}
if (subject.embed) {
let embedHidden = false
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
embedHidden = hiddenPosts.includes(subject.embed.record.uri)
}
if (
AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
) {
embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
}
if (embedHidden) {
moderations.embed.filter = true
moderations.embed.blur = true
if (!moderations.embed.cause) {
moderations.embed.cause = {
// @ts-ignore Temporary extension to the moderation system -prf
type: 'post-hidden',
source: {type: 'user'},
priority: 1,
}
}
}
}
return moderations
}
+7
View File
@@ -60,6 +60,13 @@ export function describeModerationCause(
} }
} }
} }
// @ts-ignore Temporary extension to the moderation system -prf
if (cause.type === 'post-hidden') {
return {
name: 'Post Hidden by You',
description: 'You have hidden this post',
}
}
return cause.labelDef.strings[context].en return cause.labelDef.strings[context].en
} }
+30 -2
View File
@@ -1,11 +1,39 @@
import {QueryClient} from '@tanstack/react-query' import {AppState, AppStateStatus} from 'react-native'
import {QueryClient, focusManager} from '@tanstack/react-query'
import {isNative} from '#/platform/detection'
focusManager.setEventListener(onFocus => {
if (isNative) {
const subscription = AppState.addEventListener(
'change',
(status: AppStateStatus) => {
focusManager.setFocused(status === 'active')
},
)
return () => subscription.remove()
} else if (typeof window !== 'undefined' && window.addEventListener) {
// these handlers are a bit redundant but focus catches when the browser window
// is blurred/focused while visibilitychange seems to only handle when the
// window minimizes (both of them catch tab changes)
// there's no harm to redundant fires because refetchOnWindowFocus is only
// used with queries that employ stale data times
const handler = () => onFocus()
window.addEventListener('focus', handler, false)
window.addEventListener('visibilitychange', handler, false)
return () => {
window.removeEventListener('visibilitychange', handler)
window.removeEventListener('focus', handler)
}
}
})
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
// NOTE // NOTE
// refetchOnWindowFocus breaks some UIs (like feeds) // refetchOnWindowFocus breaks some UIs (like feeds)
// so we NEVER want to enable this // so we only selectively want to enable this
// -prf // -prf
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
// Structural sharing between responses makes it impossible to rely on // Structural sharing between responses makes it impossible to rely on
+1
View File
@@ -32,6 +32,7 @@ export type CommonNavigatorParams = {
SavedFeeds: undefined SavedFeeds: undefined
PreferencesHomeFeed: undefined PreferencesHomeFeed: undefined
PreferencesThreads: undefined PreferencesThreads: undefined
PreferencesExternalEmbeds: undefined
} }
export type BottomTabNavigatorParams = CommonNavigatorParams & { export type BottomTabNavigatorParams = CommonNavigatorParams & {
+300 -44
View File
@@ -1,15 +1,59 @@
export type EmbedPlayerParams = import {Dimensions, Platform} from 'react-native'
| {type: 'youtube_video'; videoId: string; playerUri: string} const {height: SCREEN_HEIGHT} = Dimensions.get('window')
| {type: 'twitch_live'; channelId: string; playerUri: string}
| {type: 'spotify_album'; albumId: string; playerUri: string} export const embedPlayerSources = [
| { 'youtube',
type: 'spotify_playlist' 'youtubeShorts',
playlistId: string 'twitch',
'spotify',
'soundcloud',
'appleMusic',
'vimeo',
'giphy',
'tenor',
] as const
export type EmbedPlayerSource = (typeof embedPlayerSources)[number]
export type EmbedPlayerType =
| 'youtube_video'
| 'youtube_short'
| 'twitch_video'
| 'spotify_album'
| 'spotify_playlist'
| 'spotify_song'
| 'soundcloud_track'
| 'soundcloud_set'
| 'apple_music_playlist'
| 'apple_music_album'
| 'apple_music_song'
| 'vimeo_video'
| 'giphy_gif'
| 'tenor_gif'
export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
youtube: 'YouTube',
youtubeShorts: 'YouTube Shorts',
vimeo: 'Vimeo',
twitch: 'Twitch',
giphy: 'GIPHY',
tenor: 'Tenor',
spotify: 'Spotify',
appleMusic: 'Apple Music',
soundcloud: 'SoundCloud',
}
export interface EmbedPlayerParams {
type: EmbedPlayerType
playerUri: string playerUri: string
} isGif?: boolean
| {type: 'spotify_song'; songId: string; playerUri: string} source: EmbedPlayerSource
| {type: 'soundcloud_track'; user: string; track: string; playerUri: string} metaUri?: string
| {type: 'soundcloud_set'; user: string; set: string; playerUri: string} hideDetails?: boolean
}
const giphyRegex = /media(?:[0-4]\.giphy\.com|\.giphy\.com)/i
const gifFilenameRegex = /^(\S+)\.(webp|gif|mp4)$/i
export function parseEmbedPlayerFromUrl( export function parseEmbedPlayerFromUrl(
url: string, url: string,
@@ -27,60 +71,88 @@ export function parseEmbedPlayerFromUrl(
if (videoId) { if (videoId) {
return { return {
type: 'youtube_video', type: 'youtube_video',
videoId, source: 'youtube',
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`, playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1`,
} }
} }
} }
if (urlp.hostname === 'www.youtube.com' || urlp.hostname === 'youtube.com') { if (
urlp.hostname === 'www.youtube.com' ||
urlp.hostname === 'youtube.com' ||
urlp.hostname === 'm.youtube.com'
) {
const [_, page, shortVideoId] = urlp.pathname.split('/') const [_, page, shortVideoId] = urlp.pathname.split('/')
const videoId = const videoId =
page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string) page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string)
if (videoId) { if (videoId) {
return { return {
type: 'youtube_video', type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
videoId, source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`, hideDetails: page === 'shorts' ? true : undefined,
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1`,
} }
} }
} }
// twitch // twitch
if (urlp.hostname === 'twitch.tv' || urlp.hostname === 'www.twitch.tv') { if (
const parts = urlp.pathname.split('/') urlp.hostname === 'twitch.tv' ||
if (parts.length === 2 && parts[1]) { urlp.hostname === 'www.twitch.tv' ||
urlp.hostname === 'm.twitch.tv'
) {
const parent =
Platform.OS === 'web' ? window.location.hostname : 'localhost'
const [_, channelOrVideo, clipOrId, id] = urlp.pathname.split('/')
if (channelOrVideo === 'videos') {
return { return {
type: 'twitch_live', type: 'twitch_video',
channelId: parts[1], source: 'twitch',
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${parts[1]}&parent=localhost`, playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&video=${clipOrId}&parent=${parent}`,
}
} else if (clipOrId === 'clip') {
return {
type: 'twitch_video',
source: 'twitch',
playerUri: `https://clips.twitch.tv/embed?volume=0.5&autoplay=true&clip=${id}&parent=${parent}`,
}
} else if (channelOrVideo) {
return {
type: 'twitch_video',
source: 'twitch',
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${channelOrVideo}&parent=${parent}`,
} }
} }
} }
// spotify // spotify
if (urlp.hostname === 'open.spotify.com') { if (urlp.hostname === 'open.spotify.com') {
const [_, type, id] = urlp.pathname.split('/') const [_, typeOrLocale, idOrType, id] = urlp.pathname.split('/')
if (type && id) {
if (type === 'playlist') { if (idOrType) {
if (typeOrLocale === 'playlist' || idOrType === 'playlist') {
return { return {
type: 'spotify_playlist', type: 'spotify_playlist',
playlistId: id, source: 'spotify',
playerUri: `https://open.spotify.com/embed/playlist/${id}`, playerUri: `https://open.spotify.com/embed/playlist/${
id ?? idOrType
}`,
} }
} }
if (type === 'album') { if (typeOrLocale === 'album' || idOrType === 'album') {
return { return {
type: 'spotify_album', type: 'spotify_album',
albumId: id, source: 'spotify',
playerUri: `https://open.spotify.com/embed/album/${id}`, playerUri: `https://open.spotify.com/embed/album/${id ?? idOrType}`,
} }
} }
if (type === 'track') { if (typeOrLocale === 'track' || idOrType === 'track') {
return { return {
type: 'spotify_song', type: 'spotify_song',
songId: id, source: 'spotify',
playerUri: `https://open.spotify.com/embed/track/${id}`, playerUri: `https://open.spotify.com/embed/track/${id ?? idOrType}`,
} }
} }
} }
@@ -97,20 +169,173 @@ export function parseEmbedPlayerFromUrl(
if (trackOrSets === 'sets' && set) { if (trackOrSets === 'sets' && set) {
return { return {
type: 'soundcloud_set', type: 'soundcloud_set',
user, source: 'soundcloud',
set: set,
playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`, playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
} }
} }
return { return {
type: 'soundcloud_track', type: 'soundcloud_track',
user, source: 'soundcloud',
track: trackOrSets,
playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`, playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
} }
} }
} }
if (
urlp.hostname === 'music.apple.com' ||
urlp.hostname === 'music.apple.com'
) {
// This should always have: locale, type (playlist or album), name, and id. We won't use spread since we want
// to check if the length is correct
const pathParams = urlp.pathname.split('/')
const type = pathParams[2]
const songId = urlp.searchParams.get('i')
if (pathParams.length === 5 && (type === 'playlist' || type === 'album')) {
// We want to append the songId to the end of the url if it exists
const embedUri = `https://embed.music.apple.com${urlp.pathname}${
urlp.search ? '?i=' + songId : ''
}`
if (type === 'playlist') {
return {
type: 'apple_music_playlist',
source: 'appleMusic',
playerUri: embedUri,
}
} else if (type === 'album') {
if (songId) {
return {
type: 'apple_music_song',
source: 'appleMusic',
playerUri: embedUri,
}
} else {
return {
type: 'apple_music_album',
source: 'appleMusic',
playerUri: embedUri,
}
}
}
}
}
if (urlp.hostname === 'vimeo.com' || urlp.hostname === 'www.vimeo.com') {
const [_, videoId] = urlp.pathname.split('/')
if (videoId) {
return {
type: 'vimeo_video',
source: 'vimeo',
playerUri: `https://player.vimeo.com/video/${videoId}?autoplay=1`,
}
}
}
if (urlp.hostname === 'giphy.com' || urlp.hostname === 'www.giphy.com') {
const [_, gifs, nameAndId] = urlp.pathname.split('/')
/*
* nameAndId is a string that consists of the name (dash separated) and the id of the gif (the last part of the name)
* We want to get the id of the gif, then direct to media.giphy.com/media/{id}/giphy.webp so we can
* use it in an <Image> component
*/
if (gifs === 'gifs' && nameAndId) {
const gifId = nameAndId.split('-').pop()
if (gifId) {
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${gifId}`,
playerUri: `https://i.giphy.com/media/${gifId}/giphy.webp`,
}
}
}
}
// There are five possible hostnames that also can be giphy urls: media.giphy.com and media0-4.giphy.com
// These can include (presumably) a tracking id in the path name, so we have to check for that as well
if (giphyRegex.test(urlp.hostname)) {
// We can link directly to the gif, if its a proper link
const [_, media, trackingOrId, idOrFilename, filename] =
urlp.pathname.split('/')
if (media === 'media') {
if (idOrFilename && gifFilenameRegex.test(idOrFilename)) {
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${trackingOrId}`,
playerUri: `https://i.giphy.com/media/${trackingOrId}/giphy.webp`,
}
} else if (filename && gifFilenameRegex.test(filename)) {
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${idOrFilename}`,
playerUri: `https://i.giphy.com/media/${idOrFilename}/giphy.webp`,
}
}
}
}
// Finally, we should see if it is a link to i.giphy.com. These links don't necessarily end in .gif but can also
// be .webp
if (urlp.hostname === 'i.giphy.com' || urlp.hostname === 'www.i.giphy.com') {
const [_, mediaOrFilename, filename] = urlp.pathname.split('/')
if (mediaOrFilename === 'media' && filename) {
const gifId = filename.split('.')[0]
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${gifId}`,
playerUri: `https://i.giphy.com/media/${gifId}/giphy.webp`,
}
} else if (mediaOrFilename) {
const gifId = mediaOrFilename.split('.')[0]
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${gifId}`,
playerUri: `https://i.giphy.com/media/${
mediaOrFilename.split('.')[0]
}/giphy.webp`,
}
}
}
if (urlp.hostname === 'tenor.com' || urlp.hostname === 'www.tenor.com') {
const [_, pathOrIntl, pathOrFilename, intlFilename] =
urlp.pathname.split('/')
const isIntl = pathOrFilename === 'view'
const filename = isIntl ? intlFilename : pathOrFilename
if ((pathOrIntl === 'view' || pathOrFilename === 'view') && filename) {
const includesExt = filename.split('.').pop() === 'gif'
return {
type: 'tenor_gif',
source: 'tenor',
isGif: true,
hideDetails: true,
playerUri: `${url}${!includesExt ? '.gif' : ''}`,
}
}
}
} }
export function getPlayerHeight({ export function getPlayerHeight({
@@ -126,22 +351,53 @@ export function getPlayerHeight({
switch (type) { switch (type) {
case 'youtube_video': case 'youtube_video':
case 'twitch_live': case 'twitch_video':
case 'vimeo_video':
return (width / 16) * 9 return (width / 16) * 9
case 'youtube_short':
if (SCREEN_HEIGHT < 600) {
return ((width / 9) * 16) / 1.75
} else {
return ((width / 9) * 16) / 1.5
}
case 'spotify_album': case 'spotify_album':
return 380 case 'apple_music_album':
case 'apple_music_playlist':
case 'spotify_playlist': case 'spotify_playlist':
return 360 case 'soundcloud_set':
return 380
case 'spotify_song': case 'spotify_song':
if (width <= 300) { if (width <= 300) {
return 180 return 155
} }
return 232 return 232
case 'soundcloud_track': case 'soundcloud_track':
return 165 return 165
case 'soundcloud_set': case 'apple_music_song':
return 360 return 150
default: default:
return width return width
} }
} }
export function getGifDims(
originalHeight: number,
originalWidth: number,
viewWidth: number,
) {
const scaledHeight = (originalHeight / originalWidth) * viewWidth
return {
height: scaledHeight > 250 ? 250 : scaledHeight,
width: (250 / scaledHeight) * viewWidth,
}
}
export function getGiphyMetaUri(url: URL) {
if (giphyRegex.test(url.hostname) || url.hostname === 'i.giphy.com') {
const params = parseEmbedPlayerFromUrl(url.toString())
if (params && params.type === 'giphy_gif') {
return params.metaUri
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
import {linkRequiresWarning} from './url-helpers'
export function richTextToString(rt: RichText): string {
const {text, facets} = rt
if (!facets?.length) {
return text
}
let result = ''
for (const segment of rt.segments()) {
const link = segment.link
if (link && AppBskyRichtextFacet.validateLink(link).success) {
const href = link.uri
const text = segment.text
const requiresWarning = linkRequiresWarning(href, text)
result += !requiresWarning ? href : `[${text}](${href})`
} else {
result += segment.text
}
}
return result
}
+1
View File
@@ -167,6 +167,7 @@ export const s = StyleSheet.create({
flexGrow1: {flexGrow: 1}, flexGrow1: {flexGrow: 1},
alignCenter: {alignItems: 'center'}, alignCenter: {alignItems: 'center'},
alignBaseline: {alignItems: 'baseline'}, alignBaseline: {alignItems: 'baseline'},
justifyCenter: {justifyContent: 'center'},
// position // position
absolute: {position: 'absolute'}, absolute: {position: 'absolute'},
+2
View File
@@ -25,6 +25,7 @@ export const defaultTheme: Theme = {
postCtrl: '#71768A', postCtrl: '#71768A',
brandText: '#0066FF', brandText: '#0066FF',
emptyStateIcon: '#B6B6C9', emptyStateIcon: '#B6B6C9',
borderLinkHover: '#cac1c1',
}, },
primary: { primary: {
background: colors.blue3, background: colors.blue3,
@@ -310,6 +311,7 @@ export const darkTheme: Theme = {
postCtrl: '#707489', postCtrl: '#707489',
brandText: '#0085ff', brandText: '#0085ff',
emptyStateIcon: colors.gray4, emptyStateIcon: colors.gray4,
borderLinkHover: colors.gray5,
}, },
primary: { primary: {
...defaultTheme.palette.primary, ...defaultTheme.palette.primary,
+2
View File
@@ -5,7 +5,9 @@ import {AppLanguage} from '#/locale/languages'
test('sanitizeAppLanguageSetting', () => { test('sanitizeAppLanguageSetting', () => {
expect(sanitizeAppLanguageSetting('en')).toBe(AppLanguage.en) expect(sanitizeAppLanguageSetting('en')).toBe(AppLanguage.en)
expect(sanitizeAppLanguageSetting('pt-BR')).toBe(AppLanguage.pt_BR)
expect(sanitizeAppLanguageSetting('hi')).toBe(AppLanguage.hi) expect(sanitizeAppLanguageSetting('hi')).toBe(AppLanguage.hi)
expect(sanitizeAppLanguageSetting('id')).toBe(AppLanguage.id)
expect(sanitizeAppLanguageSetting('foo')).toBe(AppLanguage.en) expect(sanitizeAppLanguageSetting('foo')).toBe(AppLanguage.en)
expect(sanitizeAppLanguageSetting('en,foo')).toBe(AppLanguage.en) expect(sanitizeAppLanguageSetting('en,foo')).toBe(AppLanguage.en)
expect(sanitizeAppLanguageSetting('foo,en')).toBe(AppLanguage.en) expect(sanitizeAppLanguageSetting('foo,en')).toBe(AppLanguage.en)
+17 -9
View File
@@ -110,20 +110,28 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
switch (lang) { switch (lang) {
case 'en': case 'en':
return AppLanguage.en return AppLanguage.en
case 'hi': // DISABLED until this translation is fixed -prf
return AppLanguage.hi // case 'de':
case 'ja': // return AppLanguage.de
return AppLanguage.ja
case 'fr':
return AppLanguage.fr
case 'de':
return AppLanguage.de
case 'es': case 'es':
return AppLanguage.es return AppLanguage.es
case 'fr':
return AppLanguage.fr
case 'hi':
return AppLanguage.hi
case 'id':
return AppLanguage.id
case 'ja':
return AppLanguage.ja
case 'ko':
return AppLanguage.ko
case 'pt-BR':
return AppLanguage.pt_BR
case 'uk':
return AppLanguage.uk
default: default:
continue continue
} }
} }
return AppLanguage.en return AppLanguage.en
} }
+35 -13
View File
@@ -3,11 +3,16 @@ import {i18n} from '@lingui/core'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import {messages as messagesEn} from '#/locale/locales/en/messages' import {messages as messagesEn} from '#/locale/locales/en/messages'
// DISABLED until this translation is fixed -prf
// import {messages as messagesDe} from '#/locale/locales/de/messages'
import {messages as messagesId} from '#/locale/locales/id/messages'
import {messages as messagesEs} from '#/locale/locales/es/messages'
import {messages as messagesFr} from '#/locale/locales/fr/messages'
import {messages as messagesHi} from '#/locale/locales/hi/messages' import {messages as messagesHi} from '#/locale/locales/hi/messages'
import {messages as messagesJa} from '#/locale/locales/ja/messages' import {messages as messagesJa} from '#/locale/locales/ja/messages'
import {messages as messagesFr} from '#/locale/locales/fr/messages' import {messages as messagesKo} from '#/locale/locales/ko/messages'
import {messages as messagesDe} from '#/locale/locales/de/messages' import {messages as messagesPt_BR} from '#/locale/locales/pt-BR/messages'
import {messages as messagesEs} from '#/locale/locales/de/messages' import {messages as messagesUk} from '#/locale/locales/uk/messages'
import {sanitizeAppLanguageSetting} from '#/locale/helpers' import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {AppLanguage} from '#/locale/languages' import {AppLanguage} from '#/locale/languages'
@@ -17,24 +22,41 @@ import {AppLanguage} from '#/locale/languages'
*/ */
export async function dynamicActivate(locale: AppLanguage) { export async function dynamicActivate(locale: AppLanguage) {
switch (locale) { switch (locale) {
case AppLanguage.hi: { // DISABLED until this translation is fixed -prf
i18n.loadAndActivate({locale, messages: messagesHi}) // case AppLanguage.de: {
break // i18n.loadAndActivate({locale, messages: messagesDe})
} // break
case AppLanguage.ja: { // }
i18n.loadAndActivate({locale, messages: messagesJa}) case AppLanguage.es: {
i18n.loadAndActivate({locale, messages: messagesEs})
break break
} }
case AppLanguage.fr: { case AppLanguage.fr: {
i18n.loadAndActivate({locale, messages: messagesFr}) i18n.loadAndActivate({locale, messages: messagesFr})
break break
} }
case AppLanguage.de: { case AppLanguage.hi: {
i18n.loadAndActivate({locale, messages: messagesDe}) i18n.loadAndActivate({locale, messages: messagesHi})
break break
} }
case AppLanguage.es: { case AppLanguage.id: {
i18n.loadAndActivate({locale, messages: messagesEs}) i18n.loadAndActivate({locale, messages: messagesId})
break
}
case AppLanguage.ja: {
i18n.loadAndActivate({locale, messages: messagesJa})
break
}
case AppLanguage.ko: {
i18n.loadAndActivate({locale, messages: messagesKo})
break
}
case AppLanguage.pt_BR: {
i18n.loadAndActivate({locale, messages: messagesPt_BR})
break
}
case AppLanguage.uk: {
i18n.loadAndActivate({locale, messages: messagesUk})
break break
} }
default: { default: {
+27 -10
View File
@@ -12,24 +12,41 @@ export async function dynamicActivate(locale: AppLanguage) {
let mod: any let mod: any
switch (locale) { switch (locale) {
case AppLanguage.hi: { // DISABLED until this translation is fixed -prf
mod = await import(`./locales/hi/messages`) // case AppLanguage.de: {
break // mod = await import(`./locales/de/messages`)
} // break
case AppLanguage.ja: { // }
mod = await import(`./locales/ja/messages`) case AppLanguage.es: {
mod = await import(`./locales/es/messages`)
break break
} }
case AppLanguage.fr: { case AppLanguage.fr: {
mod = await import(`./locales/fr/messages`) mod = await import(`./locales/fr/messages`)
break break
} }
case AppLanguage.de: { case AppLanguage.hi: {
mod = await import(`./locales/de/messages`) mod = await import(`./locales/hi/messages`)
break break
} }
case AppLanguage.es: { case AppLanguage.id: {
mod = await import(`./locales/es/messages`) mod = await import(`./locales/id/messages`)
break
}
case AppLanguage.ja: {
mod = await import(`./locales/ja/messages`)
break
}
case AppLanguage.ko: {
mod = await import(`./locales/ko/messages`)
break
}
case AppLanguage.pt_BR: {
mod = await import(`./locales/pt-BR/messages`)
break
}
case AppLanguage.uk: {
mod = await import(`./locales/uk/messages`)
break break
} }
default: { default: {
+18 -8
View File
@@ -6,11 +6,16 @@ interface Language {
export enum AppLanguage { export enum AppLanguage {
en = 'en', en = 'en',
hi = 'hi', // DISABLED until this translation is fixed -prf
ja = 'ja', // de = 'de',
fr = 'fr',
de = 'de',
es = 'es', es = 'es',
fr = 'fr',
hi = 'hi',
id = 'id',
ja = 'ja',
ko = 'ko',
pt_BR = 'pt-BR',
uk = 'uk',
} }
interface AppLanguageConfig { interface AppLanguageConfig {
@@ -20,11 +25,16 @@ interface AppLanguageConfig {
export const APP_LANGUAGES: AppLanguageConfig[] = [ export const APP_LANGUAGES: AppLanguageConfig[] = [
{code2: AppLanguage.en, name: 'English'}, {code2: AppLanguage.en, name: 'English'},
{code2: AppLanguage.hi, name: 'हिंदी'}, // DISABLED until this translation is fixed -prf
{code2: AppLanguage.ja, name: '日本語'}, // {code2: AppLanguage.de, name: 'Deutsch'},
{code2: AppLanguage.fr, name: 'Français'},
{code2: AppLanguage.de, name: 'Deutsch'},
{code2: AppLanguage.es, name: 'Español'}, {code2: AppLanguage.es, name: 'Español'},
{code2: AppLanguage.fr, name: 'Français'},
{code2: AppLanguage.hi, name: 'हिंदी'},
{code2: AppLanguage.id, name: 'Bahasa Indonesia'},
{code2: AppLanguage.ja, name: '日本語'},
{code2: AppLanguage.ko, name: '한국어'},
{code2: AppLanguage.pt_BR, name: 'Português (BR)'},
{code2: AppLanguage.uk, name: 'Українська'},
] ]
export const LANGUAGES: Language[] = [ export const LANGUAGES: Language[] = [
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
// HACK
// expo-modules-core tries to require('crypto') in uuid.web.js
// and while it tries to detect web crypto before doing so, our
// build fails when it tries to do this require. We use a babel
// and tsconfig alias to direct it here
// -prf
export default crypto
+4 -2
View File
@@ -14,5 +14,7 @@ export const isMobileWeb =
global.window.matchMedia(isMobileWebMediaQuery)?.matches global.window.matchMedia(isMobileWebMediaQuery)?.matches
export const deviceLocales = dedupArray( export const deviceLocales = dedupArray(
getLocales?.().map?.(locale => locale.languageCode), getLocales?.()
) .map?.(locale => locale.languageCode)
.filter(code => typeof code === 'string'),
) as string[]
+1
View File
@@ -26,6 +26,7 @@ export const router = new Router({
AppPasswords: '/settings/app-passwords', AppPasswords: '/settings/app-passwords',
PreferencesHomeFeed: '/settings/home-feed', PreferencesHomeFeed: '/settings/home-feed',
PreferencesThreads: '/settings/threads', PreferencesThreads: '/settings/threads',
PreferencesExternalEmbeds: '/settings/external-embeds',
SavedFeeds: '/settings/saved-feeds', SavedFeeds: '/settings/saved-feeds',
Support: '/support', Support: '/support',
PrivacyPolicy: '/support/privacy', PrivacyPolicy: '/support/privacy',
+8
View File
@@ -6,6 +6,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
import {ImageModel} from '#/state/models/media/image' import {ImageModel} from '#/state/models/media/image'
import {GalleryModel} from '#/state/models/media/gallery' import {GalleryModel} from '#/state/models/media/gallery'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {EmbedPlayerSource} from '#/lib/strings/embed-player.ts'
import {ThreadgateSetting} from '../queries/threadgate' import {ThreadgateSetting} from '../queries/threadgate'
export interface ConfirmModal { export interface ConfirmModal {
@@ -180,6 +181,12 @@ export interface LinkWarningModal {
href: string href: string
} }
export interface EmbedConsentModal {
name: 'embed-consent'
source: EmbedPlayerSource
onAccept: () => void
}
export type Modal = export type Modal =
// Account // Account
| AddAppPasswordModal | AddAppPasswordModal
@@ -223,6 +230,7 @@ export type Modal =
// Generic // Generic
| ConfirmModal | ConfirmModal
| LinkWarningModal | LinkWarningModal
| EmbedConsentModal
const ModalContext = React.createContext<{ const ModalContext = React.createContext<{
isModalActive: boolean isModalActive: boolean
+2
View File
@@ -108,6 +108,8 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
onboarding: { onboarding: {
step: legacy.onboarding?.step || defaults.onboarding.step, step: legacy.onboarding?.step || defaults.onboarding.step,
}, },
hiddenPosts: defaults.hiddenPosts,
externalEmbeds: defaults.externalEmbeds,
} }
} }
+18
View File
@@ -1,6 +1,8 @@
import {z} from 'zod' import {z} from 'zod'
import {deviceLocales} from '#/platform/detection' import {deviceLocales} from '#/platform/detection'
const externalEmbedOptions = ['show', 'hide'] as const
// only data needed for rendering account page // only data needed for rendering account page
const accountSchema = z.object({ const accountSchema = z.object({
service: z.string(), service: z.string(),
@@ -30,6 +32,19 @@ export const schema = z.object({
appLanguage: z.string(), appLanguage: z.string(),
}), }),
requireAltTextEnabled: z.boolean(), // should move to server requireAltTextEnabled: z.boolean(), // should move to server
externalEmbeds: z
.object({
giphy: z.enum(externalEmbedOptions).optional(),
tenor: z.enum(externalEmbedOptions).optional(),
youtube: z.enum(externalEmbedOptions).optional(),
youtubeShorts: z.enum(externalEmbedOptions).optional(),
twitch: z.enum(externalEmbedOptions).optional(),
vimeo: z.enum(externalEmbedOptions).optional(),
spotify: z.enum(externalEmbedOptions).optional(),
appleMusic: z.enum(externalEmbedOptions).optional(),
soundcloud: z.enum(externalEmbedOptions).optional(),
})
.optional(),
mutedThreads: z.array(z.string()), // should move to server mutedThreads: z.array(z.string()), // should move to server
invites: z.object({ invites: z.object({
copiedInvites: z.array(z.string()), copiedInvites: z.array(z.string()),
@@ -37,6 +52,7 @@ export const schema = z.object({
onboarding: z.object({ onboarding: z.object({
step: z.string(), step: z.string(),
}), }),
hiddenPosts: z.array(z.string()).optional(), // should move to server
}) })
export type Schema = z.infer<typeof schema> export type Schema = z.infer<typeof schema>
@@ -59,6 +75,7 @@ export const defaults: Schema = {
appLanguage: deviceLocales[0] || 'en', appLanguage: deviceLocales[0] || 'en',
}, },
requireAltTextEnabled: false, requireAltTextEnabled: false,
externalEmbeds: {},
mutedThreads: [], mutedThreads: [],
invites: { invites: {
copiedInvites: [], copiedInvites: [],
@@ -66,4 +83,5 @@ export const defaults: Schema = {
onboarding: { onboarding: {
step: 'Home', step: 'Home',
}, },
hiddenPosts: [],
} }
@@ -0,0 +1,54 @@
import React from 'react'
import * as persisted from '#/state/persisted'
import {EmbedPlayerSource} from 'lib/strings/embed-player'
type StateContext = persisted.Schema['externalEmbeds']
type SetContext = (source: EmbedPlayerSource, value: 'show' | 'hide') => void
const stateContext = React.createContext<StateContext>(
persisted.defaults.externalEmbeds,
)
const setContext = React.createContext<SetContext>({} as SetContext)
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState(persisted.get('externalEmbeds'))
const setStateWrapped = React.useCallback(
(source: EmbedPlayerSource, value: 'show' | 'hide') => {
setState(prev => {
persisted.write('externalEmbeds', {
...prev,
[source]: value,
})
return {
...prev,
[source]: value,
}
})
},
[setState],
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('externalEmbeds'))
})
}, [setStateWrapped])
return (
<stateContext.Provider value={state}>
<setContext.Provider value={setStateWrapped}>
{children}
</setContext.Provider>
</stateContext.Provider>
)
}
export function useExternalEmbedsPrefs() {
return React.useContext(stateContext)
}
export function useSetExternalEmbedPref() {
return React.useContext(setContext)
}
+64
View File
@@ -0,0 +1,64 @@
import React from 'react'
import * as persisted from '#/state/persisted'
type SetStateCb = (
s: persisted.Schema['hiddenPosts'],
) => persisted.Schema['hiddenPosts']
type StateContext = persisted.Schema['hiddenPosts']
type ApiContext = {
hidePost: ({uri}: {uri: string}) => void
unhidePost: ({uri}: {uri: string}) => void
}
const stateContext = React.createContext<StateContext>(
persisted.defaults.hiddenPosts,
)
const apiContext = React.createContext<ApiContext>({
hidePost: () => {},
unhidePost: () => {},
})
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState(persisted.get('hiddenPosts'))
const setStateWrapped = React.useCallback(
(fn: SetStateCb) => {
const s = fn(persisted.get('hiddenPosts'))
setState(s)
persisted.write('hiddenPosts', s)
},
[setState],
)
const api = React.useMemo(
() => ({
hidePost: ({uri}: {uri: string}) => {
setStateWrapped(s => [...(s || []), uri])
},
unhidePost: ({uri}: {uri: string}) => {
setStateWrapped(s => (s || []).filter(u => u !== uri))
},
}),
[setStateWrapped],
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('hiddenPosts'))
})
}, [setStateWrapped])
return (
<stateContext.Provider value={state}>
<apiContext.Provider value={api}>{children}</apiContext.Provider>
</stateContext.Provider>
)
}
export function useHiddenPosts() {
return React.useContext(stateContext)
}
export function useHiddenPostsApi() {
return React.useContext(apiContext)
}
+12 -1
View File
@@ -1,17 +1,28 @@
import React from 'react' import React from 'react'
import {Provider as LanguagesProvider} from './languages' import {Provider as LanguagesProvider} from './languages'
import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required' import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required'
import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts'
import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs'
export {useLanguagePrefs, useLanguagePrefsApi} from './languages' export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
export { export {
useRequireAltTextEnabled, useRequireAltTextEnabled,
useSetRequireAltTextEnabled, useSetRequireAltTextEnabled,
} from './alt-text-required' } from './alt-text-required'
export {
useExternalEmbedsPrefs,
useSetExternalEmbedPref,
} from './external-embeds-prefs'
export * from './hidden-posts'
export function Provider({children}: React.PropsWithChildren<{}>) { export function Provider({children}: React.PropsWithChildren<{}>) {
return ( return (
<LanguagesProvider> <LanguagesProvider>
<AltTextRequiredProvider>{children}</AltTextRequiredProvider> <AltTextRequiredProvider>
<ExternalEmbedsProvider>
<HiddenPostsProvider>{children}</HiddenPostsProvider>
</ExternalEmbedsProvider>
</AltTextRequiredProvider>
</LanguagesProvider> </LanguagesProvider>
) )
} }
+3 -1
View File
@@ -24,6 +24,8 @@ export function useActorAutocompleteQuery(prefix: string) {
const {data: follows, isFetching} = useMyFollowsQuery() const {data: follows, isFetching} = useMyFollowsQuery()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
prefix = prefix.toLowerCase()
return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({ return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({
staleTime: STALE.MINUTES.ONE, staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(prefix || ''), queryKey: RQKEY(prefix || ''),
@@ -112,7 +114,7 @@ function computeSuggestions(
} }
return items.filter(profile => { return items.filter(profile => {
const mod = moderateProfile(profile, moderationOpts) const mod = moderateProfile(profile, moderationOpts)
return !mod.account.filter return !mod.account.filter && mod.account.cause?.type !== 'muted'
}) })
} }
-1
View File
@@ -9,7 +9,6 @@ export const RQKEY = () => ['app-passwords']
export function useAppPasswordsQuery() { export function useAppPasswordsQuery() {
return useQuery({ return useQuery({
staleTime: STALE.MINUTES.FIVE, staleTime: STALE.MINUTES.FIVE,
refetchInterval: STALE.MINUTES.ONE,
queryKey: RQKEY(), queryKey: RQKEY(),
queryFn: async () => { queryFn: async () => {
const res = await getAgent().com.atproto.server.listAppPasswords({}) const res = await getAgent().com.atproto.server.listAppPasswords({})
+4 -1
View File
@@ -218,11 +218,13 @@ const FOLLOWING_FEED_STUB: FeedSourceInfo = {
export function usePinnedFeedsInfos(): { export function usePinnedFeedsInfos(): {
feeds: FeedSourceInfo[] feeds: FeedSourceInfo[]
hasPinnedCustom: boolean hasPinnedCustom: boolean
isLoading: boolean
} { } {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [tabs, setTabs] = React.useState<FeedSourceInfo[]>([ const [tabs, setTabs] = React.useState<FeedSourceInfo[]>([
FOLLOWING_FEED_STUB, FOLLOWING_FEED_STUB,
]) ])
const [isLoading, setLoading] = React.useState(true)
const {data: preferences} = usePreferencesQuery() const {data: preferences} = usePreferencesQuery()
const hasPinnedCustom = React.useMemo<boolean>(() => { const hasPinnedCustom = React.useMemo<boolean>(() => {
@@ -284,10 +286,11 @@ export function usePinnedFeedsInfos(): {
) as FeedSourceInfo[] ) as FeedSourceInfo[]
setTabs([FOLLOWING_FEED_STUB].concat(views)) setTabs([FOLLOWING_FEED_STUB].concat(views))
setLoading(false)
} }
fetchFeedInfo() fetchFeedInfo()
}, [queryClient, setTabs, preferences?.feeds?.pinned]) }, [queryClient, setTabs, preferences?.feeds?.pinned])
return {feeds: tabs, hasPinnedCustom} return {feeds: tabs, hasPinnedCustom, isLoading}
} }
-1
View File
@@ -16,7 +16,6 @@ export type InviteCodesQueryResponse = Exclude<
export function useInviteCodesQuery() { export function useInviteCodesQuery() {
return useQuery({ return useQuery({
staleTime: STALE.MINUTES.FIVE, staleTime: STALE.MINUTES.FIVE,
refetchInterval: STALE.MINUTES.FIVE,
queryKey: ['inviteCodes'], queryKey: ['inviteCodes'],
queryFn: async () => { queryFn: async () => {
const res = await getAgent() const res = await getAgent()
+1
View File
@@ -35,4 +35,5 @@ export interface CachedFeedPage {
usableInFeed: boolean usableInFeed: boolean
syncedAt: Date syncedAt: Date
data: FeedPage | undefined data: FeedPage | undefined
unreadCount: number
} }
+35 -3
View File
@@ -15,6 +15,7 @@ import {useMutedThreads} from '#/state/muted-threads'
import {RQKEY as RQKEY_NOTIFS} from './feed' import {RQKEY as RQKEY_NOTIFS} from './feed'
import {logger} from '#/logger' import {logger} from '#/logger'
import {truncateAndInvalidate} from '../util' import {truncateAndInvalidate} from '../util'
import {AppState} from 'react-native'
const UPDATE_INTERVAL = 30 * 1e3 // 30sec const UPDATE_INTERVAL = 30 * 1e3 // 30sec
@@ -24,7 +25,10 @@ type StateContext = string
interface ApiContext { interface ApiContext {
markAllRead: () => Promise<void> markAllRead: () => Promise<void>
checkUnread: (opts?: {invalidate?: boolean}) => Promise<void> checkUnread: (opts?: {
invalidate?: boolean
isPoll?: boolean
}) => Promise<void>
getCachedUnreadPage: () => FeedPage | undefined getCachedUnreadPage: () => FeedPage | undefined
} }
@@ -49,6 +53,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
usableInFeed: false, usableInFeed: false,
syncedAt: new Date(), syncedAt: new Date(),
data: undefined, data: undefined,
unreadCount: 0,
}) })
// periodic sync // periodic sync
@@ -57,7 +62,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return return
} }
checkUnreadRef.current() // fire on init checkUnreadRef.current() // fire on init
const interval = setInterval(checkUnreadRef.current, UPDATE_INTERVAL) const interval = setInterval(
() => checkUnreadRef.current?.({isPoll: true}),
UPDATE_INTERVAL,
)
return () => clearInterval(interval) return () => clearInterval(interval)
}, [hasSession]) }, [hasSession])
@@ -68,6 +76,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
usableInFeed: false, usableInFeed: false,
syncedAt: new Date(), syncedAt: new Date(),
data: undefined, data: undefined,
unreadCount:
data.event === '30+'
? 30
: data.event === ''
? 0
: parseInt(data.event, 10) || 1,
} }
setNumUnread(data.event) setNumUnread(data.event)
} }
@@ -89,11 +103,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
// update & broadcast // update & broadcast
setNumUnread('') setNumUnread('')
broadcast.postMessage({event: ''}) broadcast.postMessage({event: ''})
if (isNative) {
Notifications.setBadgeCountAsync(0)
}
}, },
async checkUnread({invalidate}: {invalidate?: boolean} = {}) { async checkUnread({
invalidate,
isPoll,
}: {invalidate?: boolean; isPoll?: boolean} = {}) {
try { try {
if (!getAgent().session) return if (!getAgent().session) return
if (AppState.currentState !== 'active') {
return
}
// reduce polling if unread count is set
if (isPoll && cacheRef.current?.unreadCount !== 0) {
// if hit 30+ then don't poll, otherwise reduce polling by 50%
if (cacheRef.current?.unreadCount >= 30 || Math.random() >= 0.5) {
return
}
}
// count // count
const page = await fetchPage({ const page = await fetchPage({
@@ -126,6 +157,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
usableInFeed: !!invalidate, // will be used immediately usableInFeed: !!invalidate, // will be used immediately
data: page, data: page,
syncedAt: !lastIndexed || now > lastIndexed ? now : lastIndexed, syncedAt: !lastIndexed || now > lastIndexed ? now : lastIndexed,
unreadCount,
} }
// update & broadcast // update & broadcast
+4 -2
View File
@@ -2,12 +2,12 @@ import {
AppBskyNotificationListNotifications, AppBskyNotificationListNotifications,
ModerationOpts, ModerationOpts,
moderateProfile, moderateProfile,
moderatePost,
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedPost, AppBskyFeedPost,
AppBskyFeedRepost, AppBskyFeedRepost,
AppBskyFeedLike, AppBskyFeedLike,
} from '@atproto/api' } from '@atproto/api'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import chunk from 'lodash.chunk' import chunk from 'lodash.chunk'
import {QueryClient} from '@tanstack/react-query' import {QueryClient} from '@tanstack/react-query'
import {getAgent} from '../../session' import {getAgent} from '../../session'
@@ -156,7 +156,7 @@ async function fetchSubjects(
): Promise<Map<string, AppBskyFeedDefs.PostView>> { ): Promise<Map<string, AppBskyFeedDefs.PostView>> {
const uris = new Set<string>() const uris = new Set<string>()
for (const notif of groupedNotifs) { for (const notif of groupedNotifs) {
if (notif.subjectUri) { if (notif.subjectUri && !notif.subjectUri.includes('feed.generator')) {
uris.add(notif.subjectUri) uris.add(notif.subjectUri)
} }
} }
@@ -216,6 +216,8 @@ function getSubjectUri(
? notif.record.subject?.uri ? notif.record.subject?.uri
: undefined : undefined
} }
} else if (type === 'feedgen-like') {
return notif.reasonSubject
} }
} }
+6 -6
View File
@@ -1,10 +1,6 @@
import React, {useCallback, useEffect, useRef} from 'react' import React, {useCallback, useEffect, useRef} from 'react'
import { import {AppState} from 'react-native'
AppBskyFeedDefs, import {AppBskyFeedDefs, AppBskyFeedPost, PostModeration} from '@atproto/api'
AppBskyFeedPost,
moderatePost,
PostModeration,
} from '@atproto/api'
import { import {
useInfiniteQuery, useInfiniteQuery,
InfiniteData, InfiniteData,
@@ -12,6 +8,7 @@ import {
QueryClient, QueryClient,
useQueryClient, useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {useFeedTuners} from '../preferences/feed-tuners' import {useFeedTuners} from '../preferences/feed-tuners'
import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip' import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip'
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types' import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
@@ -316,6 +313,9 @@ export async function pollLatest(page: FeedPage | undefined) {
if (!page) { if (!page) {
return false return false
} }
if (AppState.currentState !== 'active') {
return
}
logger.debug('usePostFeedQuery: pollLatest') logger.debug('usePostFeedQuery: pollLatest')
const post = await page.api.peekLatest() const post = await page.api.peekLatest()
+10 -3
View File
@@ -19,6 +19,7 @@ import {
} from '#/state/queries/preferences/const' } from '#/state/queries/preferences/const'
import {getModerationOpts} from '#/state/queries/preferences/moderation' import {getModerationOpts} from '#/state/queries/preferences/moderation'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {useHiddenPosts} from '#/state/preferences/hidden-posts'
export * from '#/state/queries/preferences/types' export * from '#/state/queries/preferences/types'
export * from '#/state/queries/preferences/moderation' export * from '#/state/queries/preferences/moderation'
@@ -30,7 +31,7 @@ export function usePreferencesQuery() {
return useQuery({ return useQuery({
staleTime: STALE.SECONDS.FIFTEEN, staleTime: STALE.SECONDS.FIFTEEN,
structuralSharing: true, structuralSharing: true,
refetchInterval: STALE.SECONDS.FIFTEEN, refetchOnWindowFocus: true,
queryKey: preferencesQueryKey, queryKey: preferencesQueryKey,
queryFn: async () => { queryFn: async () => {
const agent = getAgent() const agent = getAgent()
@@ -94,15 +95,21 @@ export function usePreferencesQuery() {
export function useModerationOpts() { export function useModerationOpts() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const prefs = usePreferencesQuery() const prefs = usePreferencesQuery()
const hiddenPosts = useHiddenPosts()
const opts = useMemo(() => { const opts = useMemo(() => {
if (!prefs.data) { if (!prefs.data) {
return return
} }
return getModerationOpts({ const moderationOpts = getModerationOpts({
userDid: currentAccount?.did || '', userDid: currentAccount?.did || '',
preferences: prefs.data, preferences: prefs.data,
}) })
}, [currentAccount?.did, prefs.data])
return {
...moderationOpts,
hiddenPosts,
}
}, [currentAccount?.did, prefs.data, hiddenPosts])
return opts return opts
} }
+1 -1
View File
@@ -35,7 +35,7 @@ export function useProfileQuery({did}: {did: string | undefined}) {
// if you remove it, the UI infinite-loops // if you remove it, the UI infinite-loops
// -prf // -prf
staleTime: isCurrentAccount ? STALE.SECONDS.THIRTY : STALE.MINUTES.FIVE, staleTime: isCurrentAccount ? STALE.SECONDS.THIRTY : STALE.MINUTES.FIVE,
refetchInterval: STALE.MINUTES.FIVE, refetchOnWindowFocus: true,
queryKey: RQKEY(did || ''), queryKey: RQKEY(did || ''),
queryFn: async () => { queryFn: async () => {
const res = await getAgent().getProfile({actor: did || ''}) const res = await getAgent().getProfile({actor: did || ''})
+46 -28
View File
@@ -102,10 +102,21 @@ function createPersistSessionHandler(
expired: boolean expired: boolean
refreshedAccount: SessionAccount refreshedAccount: SessionAccount
}) => void, }) => void,
{
networkErrorCallback,
}: {
networkErrorCallback?: () => void
} = {},
): AtpPersistSessionHandler { ): AtpPersistSessionHandler {
return function persistSession(event, session) { return function persistSession(event, session) {
const expired = event === 'expired' || event === 'create-failed' const expired = event === 'expired' || event === 'create-failed'
if (event === 'network-error') {
logger.warn(`session: persistSessionHandler received network-error event`)
networkErrorCallback?.()
return
}
const refreshedAccount: SessionAccount = { const refreshedAccount: SessionAccount = {
service: account.service, service: account.service,
did: session?.did || account.did, did: session?.did || account.did,
@@ -125,9 +136,11 @@ function createPersistSessionHandler(
event, event,
did: refreshedAccount.did, did: refreshedAccount.did,
handle: refreshedAccount.handle, handle: refreshedAccount.handle,
service: refreshedAccount.service,
}) })
if (expired) { if (expired) {
logger.warn(`session: expired`)
emitSessionDropped() emitSessionDropped()
} }
@@ -179,16 +192,26 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[setStateAndPersist], [setStateAndPersist],
) )
const createAccount = React.useCallback<ApiContext['createAccount']>( const clearCurrentAccount = React.useCallback(() => {
async ({service, email, password, handle, inviteCode}: any) => {
logger.debug( logger.debug(
`session: creating account`, `session: clear current account`,
{ {},
service,
handle,
},
logger.DebugContext.session, logger.DebugContext.session,
) )
__globalAgent = PUBLIC_BSKY_AGENT
queryClient.clear()
setStateAndPersist(s => ({
...s,
currentAccount: undefined,
}))
}, [setStateAndPersist, queryClient])
const createAccount = React.useCallback<ApiContext['createAccount']>(
async ({service, email, password, handle, inviteCode}: any) => {
logger.info(`session: creating account`, {
service,
handle,
})
track('Try Create Account') track('Try Create Account')
const agent = new BskyAgent({service}) const agent = new BskyAgent({service})
@@ -215,9 +238,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
} }
agent.setPersistSessionHandler( agent.setPersistSessionHandler(
createPersistSessionHandler(account, ({expired, refreshedAccount}) => { createPersistSessionHandler(
account,
({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired) upsertAccount(refreshedAccount, expired)
}), },
{networkErrorCallback: clearCurrentAccount},
),
) )
__globalAgent = agent __globalAgent = agent
@@ -234,7 +261,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
) )
track('Create Account') track('Create Account')
}, },
[upsertAccount, queryClient], [upsertAccount, queryClient, clearCurrentAccount],
) )
const login = React.useCallback<ApiContext['login']>( const login = React.useCallback<ApiContext['login']>(
@@ -267,9 +294,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
} }
agent.setPersistSessionHandler( agent.setPersistSessionHandler(
createPersistSessionHandler(account, ({expired, refreshedAccount}) => { createPersistSessionHandler(
account,
({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired) upsertAccount(refreshedAccount, expired)
}), },
{networkErrorCallback: clearCurrentAccount},
),
) )
__globalAgent = agent __globalAgent = agent
@@ -287,23 +318,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
track('Sign In', {resumedSession: false}) track('Sign In', {resumedSession: false})
}, },
[upsertAccount, queryClient], [upsertAccount, queryClient, clearCurrentAccount],
) )
const clearCurrentAccount = React.useCallback(() => {
logger.debug(
`session: clear current account`,
{},
logger.DebugContext.session,
)
__globalAgent = PUBLIC_BSKY_AGENT
queryClient.clear()
setStateAndPersist(s => ({
...s,
currentAccount: undefined,
}))
}, [setStateAndPersist, queryClient])
const logout = React.useCallback<ApiContext['logout']>(async () => { const logout = React.useCallback<ApiContext['logout']>(async () => {
clearCurrentAccount() clearCurrentAccount()
logger.debug(`session: logout`, {}, logger.DebugContext.session) logger.debug(`session: logout`, {}, logger.DebugContext.session)
@@ -337,6 +354,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
({expired, refreshedAccount}) => { ({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired) upsertAccount(refreshedAccount, expired)
}, },
{networkErrorCallback: clearCurrentAccount},
), ),
}) })
@@ -437,7 +455,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
} }
} }
}, },
[upsertAccount, queryClient], [upsertAccount, queryClient, clearCurrentAccount],
) )
const resumeSession = React.useCallback<ApiContext['resumeSession']>( const resumeSession = React.useCallback<ApiContext['resumeSession']>(
+2
View File
@@ -11,6 +11,7 @@ export interface ComposerOptsPostRef {
displayName?: string displayName?: string
avatar?: string avatar?: string
} }
embed?: AppBskyEmbedRecord.ViewRecord['embed']
} }
export interface ComposerOptsQuote { export interface ComposerOptsQuote {
uri: string uri: string
@@ -30,6 +31,7 @@ export interface ComposerOpts {
onPost?: () => void onPost?: () => void
quote?: ComposerOptsQuote quote?: ComposerOptsQuote
mention?: string // handle of user to mention mention?: string // handle of user to mention
openPicker?: (pos: DOMRect | undefined) => void
} }
type StateContext = ComposerOpts | undefined type StateContext = ComposerOpts | undefined
+204
View File
@@ -0,0 +1,204 @@
import React from 'react'
import {Pressable, Text, PressableProps, TextProps} from 'react-native'
import * as tokens from '#/alf/tokens'
import {atoms} from '#/alf'
export type ButtonType =
| 'primary'
| 'secondary'
| 'tertiary'
| 'positive'
| 'negative'
export type ButtonSize = 'small' | 'large'
export type VariantProps = {
type?: ButtonType
size?: ButtonSize
}
type ButtonState = {
pressed: boolean
hovered: boolean
focused: boolean
}
export type ButtonProps = Omit<PressableProps, 'children'> &
VariantProps & {
children:
| ((props: {
state: ButtonState
type?: ButtonType
size?: ButtonSize
}) => React.ReactNode)
| React.ReactNode
| string
}
export type ButtonTextProps = TextProps & VariantProps
export function Button({children, style, type, size, ...rest}: ButtonProps) {
const {baseStyles, hoverStyles} = React.useMemo(() => {
const baseStyles = []
const hoverStyles = []
switch (type) {
case 'primary':
baseStyles.push({
backgroundColor: tokens.color.blue_500,
})
break
case 'secondary':
baseStyles.push({
backgroundColor: tokens.color.gray_200,
})
hoverStyles.push({
backgroundColor: tokens.color.gray_100,
})
break
default:
}
switch (size) {
case 'large':
baseStyles.push(
atoms.py_md,
atoms.px_xl,
atoms.rounded_md,
atoms.gap_sm,
)
break
case 'small':
baseStyles.push(
atoms.py_sm,
atoms.px_md,
atoms.rounded_sm,
atoms.gap_xs,
)
break
default:
}
return {
baseStyles,
hoverStyles,
}
}, [type, size])
const [state, setState] = React.useState({
pressed: false,
hovered: false,
focused: false,
})
const onPressIn = React.useCallback(() => {
setState(s => ({
...s,
pressed: true,
}))
}, [setState])
const onPressOut = React.useCallback(() => {
setState(s => ({
...s,
pressed: false,
}))
}, [setState])
const onHoverIn = React.useCallback(() => {
setState(s => ({
...s,
hovered: true,
}))
}, [setState])
const onHoverOut = React.useCallback(() => {
setState(s => ({
...s,
hovered: false,
}))
}, [setState])
const onFocus = React.useCallback(() => {
setState(s => ({
...s,
focused: true,
}))
}, [setState])
const onBlur = React.useCallback(() => {
setState(s => ({
...s,
focused: false,
}))
}, [setState])
return (
<Pressable
{...rest}
style={state => [
atoms.flex_row,
atoms.align_center,
...baseStyles,
...(state.hovered ? hoverStyles : []),
typeof style === 'function' ? style(state) : style,
]}
onPressIn={onPressIn}
onPressOut={onPressOut}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
onFocus={onFocus}
onBlur={onBlur}>
{typeof children === 'string' ? (
<ButtonText type={type} size={size}>
{children}
</ButtonText>
) : typeof children === 'function' ? (
children({state, type, size})
) : (
children
)}
</Pressable>
)
}
export function ButtonText({
children,
style,
type,
size,
...rest
}: ButtonTextProps) {
const textStyles = React.useMemo(() => {
const base = []
switch (type) {
case 'primary':
base.push({color: tokens.color.white})
break
case 'secondary':
base.push({
color: tokens.color.gray_700,
})
break
default:
}
switch (size) {
case 'small':
base.push(atoms.text_sm, {paddingBottom: 1})
break
case 'large':
base.push(atoms.text_md, {paddingBottom: 1})
break
default:
}
return base
}, [type, size])
return (
<Text
{...rest}
style={[
atoms.flex_1,
atoms.font_semibold,
atoms.text_center,
...textStyles,
style,
]}>
{children}
</Text>
)
}
+104
View File
@@ -0,0 +1,104 @@
import React from 'react'
import {Text as RNText, TextProps} from 'react-native'
import {useTheme, atoms, web} from '#/alf'
export function Text({style, ...rest}: TextProps) {
const t = useTheme()
return <RNText style={[atoms.text_sm, t.atoms.text, style]} {...rest} />
}
export function H1({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 1,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_xl, atoms.font_bold, t.atoms.text, style]}
/>
)
}
export function H2({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 2,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_lg, atoms.font_bold, t.atoms.text, style]}
/>
)
}
export function H3({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 3,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_md, atoms.font_bold, t.atoms.text, style]}
/>
)
}
export function H4({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 4,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_sm, atoms.font_bold, t.atoms.text, style]}
/>
)
}
export function H5({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 5,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_xs, atoms.font_bold, t.atoms.text, style]}
/>
)
}
export function H6({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 6,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_xxs, atoms.font_bold, t.atoms.text, style]}
/>
)
}
+2 -2
View File
@@ -2,7 +2,7 @@ import React from 'react'
import {View, Pressable} from 'react-native' import {View, Pressable} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {isIOS, isNative} from 'platform/detection' import {isIOS, isNative} from 'platform/detection'
@@ -119,7 +119,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
}} }}
onPress={onPressSearch}> onPress={onPressSearch}>
<Text type="lg-bold" style={[pal.text]}> <Text type="lg-bold" style={[pal.text]}>
Search{' '} <Trans>Search</Trans>{' '}
</Text> </Text>
<FontAwesomeIcon <FontAwesomeIcon
icon="search" icon="search"

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