Merge branch 'main' into hailey/decrement-badge

This commit is contained in:
Hailey
2024-07-12 16:04:59 -07:00
226 changed files with 22600 additions and 13389 deletions
+2
View File
@@ -0,0 +1,2 @@
[*.{kt,kts}]
indent_size=2
@@ -1,8 +1,6 @@
name: build-and-push-ogcard-aws name: build-and-push-ogcard-aws
on: on:
push: workflow_dispatch:
branches:
- divy/bskycard
env: env:
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
+3
View File
@@ -115,3 +115,6 @@ src/locale/locales/**/*.js
*.apk *.apk
*.aab *.aab
*.ipa *.ipa
# ogcard assets
bskyogcard/src/assets/fonts/noto-*
+1 -1
View File
@@ -10,7 +10,7 @@ RUN yarn install --frozen-lockfile
COPY ./bskyogcard ./ COPY ./bskyogcard ./
# build then prune dev deps # build then prune dev deps
RUN yarn build RUN yarn install-fonts && yarn build
RUN yarn install --production --ignore-scripts --prefer-offline RUN yarn install --production --ignore-scripts --prefer-offline
# Uses assets from build stage to reduce build size # Uses assets from build stage to reduce build size
+4 -4
View File
@@ -10,7 +10,7 @@ Get the app itself:
## Development Resources ## Development Resources
This is a [React Native](https://reactnative.dev/) application, written in the TypeScript programming language. It builds on the `atproto` TypeScript packages (like [`@atproto/api`](https://www.npmjs.com/package/@atproto/api)), code for which is also on open source, but in [a different git repository](https://github.com/bluesky-social/atproto). This is a [React Native](https://reactnative.dev/) application, written in the TypeScript programming language. It builds on the `atproto` TypeScript packages (like [`@atproto/api`](https://www.npmjs.com/package/@atproto/api)), code for which is also open source, but in [a different git repository](https://github.com/bluesky-social/atproto).
There is a small amount of Go language source code (in `./bskyweb/`), for a web service that returns the React Native Web application. There is a small amount of Go language source code (in `./bskyweb/`), for a web service that returns the React Native Web application.
@@ -42,10 +42,10 @@ The Bluesky Social application encompasses a set of schemas and APIs built in th
- Open an issue and give some time for discussion before submitting a PR. - Open an issue and give some time for discussion before submitting a PR.
- Stay away from PRs like... - Stay away from PRs like...
- Changing "Post" to "Skeet." - Changing "Post" to "Skeet."
- Refactoring the codebase, eg to replace mobx with redux or something. - Refactoring the codebase, e.g., to replace MobX with Redux or something.
- Adding entirely new features without prior discussion. - Adding entirely new features without prior discussion.
Remember, we serve a wide community of users. Our day to day involves us constantly asking "which top priority is our top priority." If you submit well-written PRs that solve problems concisely, that's an awesome contribution. Otherwise, as much as we'd love to accept your ideas and contributions, we really don't have the bandwidth. That's what forking is for! Remember, we serve a wide community of users. Our day-to-day involves us constantly asking "which top priority is our top priority." If you submit well-written PRs that solve problems concisely, that's an awesome contribution. Otherwise, as much as we'd love to accept your ideas and contributions, we really don't have the bandwidth. That's what forking is for!
## Forking guidelines ## Forking guidelines
@@ -63,7 +63,7 @@ If you discover any security issues, please send an email to security@bsky.app.
## Are you a developer interested in building on atproto? ## Are you a developer interested in building on atproto?
Bluesky is an open social network built on the AT Protocol, a flexible technology that will never lock developers out of the ecosystems that they help build. With atproto, third-party can be as seamless as first-party through custom feeds, federated services, clients, and more. Bluesky is an open social network built on the AT Protocol, a flexible technology that will never lock developers out of the ecosystems that they help build. With atproto, third-party integration can be as seamless as first-party through custom feeds, federated services, clients, and more.
## License (MIT) ## License (MIT)
+3
View File
@@ -211,6 +211,8 @@ module.exports = function (config) {
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'], sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
}, },
], ],
'expo-video',
'react-native-compressor',
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js', './plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
'./plugins/withAndroidManifestPlugin.js', './plugins/withAndroidManifestPlugin.js',
'./plugins/withAndroidManifestFCMIconPlugin.js', './plugins/withAndroidManifestFCMIconPlugin.js',
@@ -219,6 +221,7 @@ module.exports = function (config) {
'./plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js', './plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js',
'./plugins/shareExtension/withShareExtensions.js', './plugins/shareExtension/withShareExtensions.js',
'./plugins/notificationsExtension/withNotificationsExtension.js', './plugins/notificationsExtension/withNotificationsExtension.js',
'./plugins/withAppDelegateReferrer.js',
].filter(Boolean), ].filter(Boolean),
extra: { extra: {
eas: { eas: {
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M21 12a1 1 0 0 1-.293.707l-6 6a1 1 0 0 1-1.414-1.414L17.586 13H4a1 1 0 1 1 0-2h13.586l-4.293-4.293a1 1 0 0 1 1.414-1.414l6 6A1 1 0 0 1 21 12Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 284 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v2h2V5H5Zm4 0v6h6V5H9Zm8 0v2h2V5h-2Zm2 4h-2v2h2V9Zm0 4h-2v2.444h2V13Zm0 4.444h-2V19h2v-1.556ZM15 19v-6H9v6h6Zm-8 0v-2H5v2h2Zm-2-4h2v-2H5v2Zm0-4h2V9H5v2Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 370 B

+17 -3
View File
@@ -11,6 +11,7 @@ export default function (ctx: AppContext, app: Express) {
'/:linkId', '/:linkId',
handler(async (req, res) => { handler(async (req, res) => {
const linkId = req.params.linkId const linkId = req.params.linkId
const contentType = req.accepts(['html', 'json'])
assert( assert(
typeof linkId === 'string', typeof linkId === 'string',
'express guarantees id parameter is a string', 'express guarantees id parameter is a string',
@@ -21,9 +22,19 @@ export default function (ctx: AppContext, app: Express) {
.where('id', '=', linkId) .where('id', '=', linkId)
.executeTakeFirst() .executeTakeFirst()
if (!found) { if (!found) {
// potentially broken or mistyped link— send user to the app // potentially broken or mistyped link
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
res.setHeader('Cache-Control', 'no-store') res.setHeader('Cache-Control', 'no-store')
if (contentType === 'json') {
return res
.status(404)
.json({
error: 'NotFound',
message: 'Link not found',
})
.end()
}
// send the user to the app
res.setHeader('Location', `https://${ctx.cfg.service.appHostname}`)
return res.status(302).end() return res.status(302).end()
} }
// build url from original url in order to preserve query params // build url from original url in order to preserve query params
@@ -32,8 +43,11 @@ export default function (ctx: AppContext, app: Express) {
`https://${ctx.cfg.service.appHostname}`, `https://${ctx.cfg.service.appHostname}`,
) )
url.pathname = found.path url.pathname = found.path
res.setHeader('Location', url.href)
res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`) res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`)
if (contentType === 'json') {
return res.json({url: url.href}).end()
}
res.setHeader('Location', url.href)
return res.status(301).end() return res.status(301).end()
}), }),
) )
+39
View File
@@ -56,6 +56,26 @@ describe('link service', async () => {
) )
}) })
it('returns json object with url when requested', async () => {
const link = await getLink('/start/did:example:carol/zzz/')
const [status, json] = await getJsonRedirect(link)
assert.strictEqual(status, 200)
assert(json.url)
const url = new URL(json.url)
assert.strictEqual(url.pathname, '/start/did:example:carol/zzz')
})
it('returns 404 for unknown link when requesting json', async () => {
const [status, json] = await getJsonRedirect(
'https://test.bsky.link/unknown',
)
assert(json.error)
assert(json.message)
assert.strictEqual(status, 404)
assert.strictEqual(json.error, 'NotFound')
assert.strictEqual(json.message, 'Link not found')
})
async function getRedirect(link: string): Promise<[number, string]> { async function getRedirect(link: string): Promise<[number, string]> {
const url = new URL(link) const url = new URL(link)
const base = new URL(baseUrl) const base = new URL(baseUrl)
@@ -70,6 +90,25 @@ describe('link service', async () => {
return [res.status, res.headers.get('location') ?? ''] return [res.status, res.headers.get('location') ?? '']
} }
async function getJsonRedirect(
link: string,
): Promise<[number, {url?: string; error?: string; message?: string}]> {
const url = new URL(link)
const base = new URL(baseUrl)
url.protocol = base.protocol
url.host = base.host
const res = await fetch(url, {
redirect: 'manual',
headers: {accept: 'application/json,text/html'},
})
assert(
res.headers.get('content-type')?.startsWith('application/json'),
'content type was not json',
)
const json = await res.json()
return [res.status, json]
}
async function getLink(path: string): Promise<string> { async function getLink(path: string): Promise<string> {
const res = await fetch(new URL('/link', baseUrl), { const res = await fetch(new URL('/link', baseUrl), {
method: 'post', method: 'post',
+6 -2
View File
@@ -5,7 +5,9 @@
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"start": "node --loader ts-node/esm ./src/bin.ts", "start": "node --loader ts-node/esm ./src/bin.ts",
"build": "tsc && cp -r src/assets dist/assets" "dev": "node --watch-path ./src --loader ts-node/esm ./src/bin.ts",
"build": "tsc && cp -r src/assets dist/",
"install-fonts": "node --loader ts-node/esm scripts/install-fonts.ts"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.12.19-next.0", "@atproto/api": "0.12.19-next.0",
@@ -15,10 +17,12 @@
"http-terminator": "^3.2.0", "http-terminator": "^3.2.0",
"pino": "^9.2.0", "pino": "^9.2.0",
"react": "^18.3.1", "react": "^18.3.1",
"satori": "^0.10.13" "satori": "^0.10.13",
"twemoji": "^14.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.14.3", "@types/node": "^20.14.3",
"ts-node": "^10.9.2",
"typescript": "^5.4.5" "typescript": "^5.4.5"
} }
} }
+40
View File
@@ -0,0 +1,40 @@
import {writeFile} from 'node:fs/promises'
import * as path from 'node:path'
import {fileURLToPath} from 'node:url'
const __DIRNAME = path.dirname(fileURLToPath(import.meta.url))
const FONTS = [
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-jp@5.0/japanese-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-tc@5.0/chinese-traditional-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-sc@5.0/chinese-simplified-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-hk@5.0/chinese-hongkong-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-kr@5.0/korean-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-thai@5.0/thai-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-arabic@5.0/arabic-700-normal.ttf',
'https://cdn.jsdelivr.net/fontsource/fonts/noto-sans-hebrew@5.0/hebrew-700-normal.ttf',
]
async function main() {
await Promise.all(
FONTS.map(async urlStr => {
const url = new URL(urlStr)
const res = await fetch(url)
const font = await res.arrayBuffer()
const filename = url.pathname
.split('/')
.slice(-2)
.join('/')
.replace(/@[\d.]+\//, '-')
if (!res.ok) {
throw new Error(`HTTP ${res.status}: fetching failed for ${filename}`)
}
await writeFile(
path.join(__DIRNAME, '..', 'src', 'assets', 'fonts', filename),
Buffer.from(font),
)
}),
)
}
main()
+4 -1
View File
@@ -43,6 +43,7 @@ export function StarterPack(props: {
} else { } else {
imagesAcross.push(...imagesExceptCreator.slice(0, 7)) imagesAcross.push(...imagesExceptCreator.slice(0, 7))
} }
const isLongTitle = record ? record.name.length > 30 : false
return ( return (
<div <div
style={{ style={{
@@ -130,7 +131,9 @@ export function StarterPack(props: {
<div <div
style={{ style={{
padding: '75px 30px 0px', padding: '75px 30px 0px',
fontSize: 65, fontSize: isLongTitle ? 55 : 65,
display: 'flex',
textAlign: 'center',
}}> }}>
{record?.name || 'Starter Pack'} {record?.name || 'Starter Pack'}
</div> </div>
+11 -9
View File
@@ -1,8 +1,8 @@
import {readFileSync} from 'node:fs' import {readdirSync, readFileSync} from 'node:fs'
import * as path from 'node:path'
import {fileURLToPath} from 'node:url'
import {AtpAgent} from '@atproto/api' import {AtpAgent} from '@atproto/api'
import * as path from 'path'
import {fileURLToPath} from 'url'
import {Config} from './config.js' import {Config} from './config.js'
@@ -28,12 +28,14 @@ export class AppContext {
static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) { static async fromConfig(cfg: Config, overrides?: Partial<AppContextOptions>) {
const appviewAgent = new AtpAgent({service: cfg.service.appviewUrl}) const appviewAgent = new AtpAgent({service: cfg.service.appviewUrl})
const fonts = [ const fontDirectory = path.join(__DIRNAME, 'assets', 'fonts')
{ const fontFiles = readdirSync(fontDirectory)
name: 'Inter', const fonts = fontFiles.map(file => {
data: readFileSync(path.join(__DIRNAME, 'assets', 'Inter-Bold.ttf')), return {
}, name: path.basename(file, path.extname(file)),
] data: readFileSync(path.join(fontDirectory, file)),
}
})
return new AppContext({ return new AppContext({
cfg, cfg,
appviewAgent, appviewAgent,
+1
View File
@@ -1,3 +1,4 @@
import {subsystemLogger} from '@atproto/common' import {subsystemLogger} from '@atproto/common'
export const httpLogger = subsystemLogger('bskyogcard') export const httpLogger = subsystemLogger('bskyogcard')
export const renderLogger = subsystemLogger('bskyogcard:render')
+6
View File
@@ -13,6 +13,7 @@ import {
} from '../components/StarterPack.js' } from '../components/StarterPack.js'
import {AppContext} from '../context.js' import {AppContext} from '../context.js'
import {httpLogger} from '../logger.js' import {httpLogger} from '../logger.js'
import {loadEmojiAsSvg} from '../util.js'
import {handler, originVerifyMiddleware} from './util.js' import {handler, originVerifyMiddleware} from './util.js'
export default function (ctx: AppContext, app: Express) { export default function (ctx: AppContext, app: Express) {
@@ -65,6 +66,11 @@ export default function (ctx: AppContext, app: Express) {
fonts: ctx.fonts, fonts: ctx.fonts,
height: STARTERPACK_HEIGHT, height: STARTERPACK_HEIGHT,
width: STARTERPACK_WIDTH, width: STARTERPACK_WIDTH,
loadAdditionalAsset: async (code, text) => {
if (code === 'emoji') {
return await loadEmojiAsSvg(text)
}
},
}, },
) )
const output = await resvg.renderAsync(svg) const output = await resvg.renderAsync(svg)
+37
View File
@@ -0,0 +1,37 @@
import twemoji from 'twemoji'
import {renderLogger} from './logger.js'
const U200D = String.fromCharCode(0x200d)
const UFE0F_REGEXP = /\uFE0F/g
export async function loadEmojiAsSvg(chars: string) {
const cached = emojiCache.get(chars)
if (cached) return cached
const iconCode = twemoji.convert.toCodePoint(
chars.indexOf(U200D) < 0 ? chars.replace(UFE0F_REGEXP, '') : chars,
)
const res = await fetch(getEmojiUrl(iconCode))
const body = await res.arrayBuffer()
if (!res.ok) {
renderLogger.warn(
{status: res.status, err: Buffer.from(body).toString()},
'could not fetch emoji',
)
return
}
const svg =
'data:image/svg+xml;base64,' + Buffer.from(body).toString('base64')
emojiCache.set(chars, svg)
return svg
}
const emojiCache = new Map<string, string>()
function getEmojiUrl(code: string) {
return (
'https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/' +
code.toLowerCase() +
'.svg'
)
}
+303 -147
View File
File diff suppressed because it is too large Load Diff
@@ -30,12 +30,11 @@ class ShareViewController: UIViewController {
} }
} }
private func handleText(item: NSItemProvider) async -> Void { private func handleText(item: NSItemProvider) async {
do { do {
if let data = try await item.loadItem(forTypeIdentifier: "public.text") as? String { if let data = try await item.loadItem(forTypeIdentifier: "public.text") as? String {
if let encoded = data.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), if let encoded = data.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") {
{
_ = self.openURL(url) _ = self.openURL(url)
} }
} }
@@ -45,12 +44,11 @@ class ShareViewController: UIViewController {
} }
} }
private func handleUrl(item: NSItemProvider) async -> Void { private func handleUrl(item: NSItemProvider) async {
do { do {
if let data = try await item.loadItem(forTypeIdentifier: "public.url") as? URL { if let data = try await item.loadItem(forTypeIdentifier: "public.url") as? URL {
if let encoded = data.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), if let encoded = data.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)") {
{
_ = self.openURL(url) _ = self.openURL(url)
} }
} }
@@ -60,7 +58,7 @@ class ShareViewController: UIViewController {
} }
} }
private func handleImages(items: [NSItemProvider]) async -> Void { private func handleImages(items: [NSItemProvider]) async {
let firstFourItems: [NSItemProvider] let firstFourItems: [NSItemProvider]
if items.count < 4 { if items.count < 4 {
firstFourItems = items firstFourItems = items
@@ -72,7 +70,7 @@ class ShareViewController: UIViewController {
var imageUris = "" var imageUris = ""
for (index, item) in firstFourItems.enumerated() { for (index, item) in firstFourItems.enumerated() {
var imageUriInfo: String? = nil var imageUriInfo: String?
do { do {
if let dataUri = try await item.loadItem(forTypeIdentifier: "public.image") as? URL { if let dataUri = try await item.loadItem(forTypeIdentifier: "public.image") as? URL {
@@ -100,8 +98,7 @@ class ShareViewController: UIViewController {
if valid, if valid,
let encoded = imageUris.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), let encoded = imageUris.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
let url = URL(string: "\(self.appScheme)://intent/compose?imageUris=\(encoded)") let url = URL(string: "\(self.appScheme)://intent/compose?imageUris=\(encoded)") {
{
_ = self.openURL(url) _ = self.openURL(url)
} }
@@ -119,13 +116,11 @@ class ShareViewController: UIViewController {
// extension does. // extension does.
if let dir = FileManager() if let dir = FileManager()
.containerURL( .containerURL(
forSecurityApplicationGroupIdentifier: "group.app.bsky") forSecurityApplicationGroupIdentifier: "group.app.bsky") {
{
let filePath = "\(dir.absoluteString)\(ProcessInfo.processInfo.globallyUniqueString).jpeg" let filePath = "\(dir.absoluteString)\(ProcessInfo.processInfo.globallyUniqueString).jpeg"
if let newUri = URL(string: filePath), if let newUri = URL(string: filePath),
let jpegData = image.jpegData(compressionQuality: 1) let jpegData = image.jpegData(compressionQuality: 1) {
{
try jpegData.write(to: newUri) try jpegData.write(to: newUri)
return "\(newUri.absoluteString)|\(image.size.width)|\(image.size.height)" return "\(newUri.absoluteString)|\(image.size.width)|\(image.size.height)"
} }
@@ -136,7 +131,7 @@ class ShareViewController: UIViewController {
} }
} }
private func completeRequest() -> Void { private func completeRequest() {
self.extensionContext?.completeRequest(returningItems: nil) self.extensionContext?.completeRequest(returningItems: nil)
} }
@@ -17,7 +17,7 @@ enum class NotificationType(val type: String) {
class BackgroundNotificationHandler( class BackgroundNotificationHandler(
private val context: Context, private val context: Context,
private val notifInterface: BackgroundNotificationHandlerInterface private val notifInterface: BackgroundNotificationHandlerInterface,
) { ) {
fun handleMessage(remoteMessage: RemoteMessage) { fun handleMessage(remoteMessage: RemoteMessage) {
if (ExpoBackgroundNotificationHandlerModule.isForegrounded) { if (ExpoBackgroundNotificationHandlerModule.isForegrounded) {
@@ -20,7 +20,8 @@ class ExpoBackgroundNotificationHandlerModule : Module() {
var isForegrounded = false var isForegrounded = false
} }
override fun definition() = ModuleDefinition { override fun definition() =
ModuleDefinition {
Name("ExpoBackgroundNotificationHandler") Name("ExpoBackgroundNotificationHandler")
OnCreate { OnCreate {
@@ -5,7 +5,10 @@ import android.graphics.Canvas
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
import androidx.appcompat.widget.AppCompatImageView import androidx.appcompat.widget.AppCompatImageView
class AppCompatImageViewExtended(context: Context, private val parent: GifView): AppCompatImageView(context) { class AppCompatImageViewExtended(
context: Context,
private val parent: GifView,
) : AppCompatImageView(context) {
override fun onDraw(canvas: Canvas) { override fun onDraw(canvas: Canvas) {
super.onDraw(canvas) super.onDraw(canvas)
@@ -6,7 +6,8 @@ import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.modules.ModuleDefinition
class ExpoBlueskyGifViewModule : Module() { class ExpoBlueskyGifViewModule : Module() {
override fun definition() = ModuleDefinition { override fun definition() =
ModuleDefinition {
Name("ExpoBlueskyGifView") Name("ExpoBlueskyGifView")
AsyncFunction("prefetchAsync") { sources: List<String> -> AsyncFunction("prefetchAsync") { sources: List<String> ->
@@ -23,7 +24,7 @@ class ExpoBlueskyGifViewModule : Module() {
View(GifView::class) { View(GifView::class) {
Events( Events(
"onPlayerStateChange" "onPlayerStateChange",
) )
Prop("source") { view: GifView, source: String -> Prop("source") { view: GifView, source: String ->
@@ -1,6 +1,5 @@
package expo.modules.blueskygifview package expo.modules.blueskygifview
import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.Animatable import android.graphics.drawable.Animatable
@@ -15,7 +14,10 @@ import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.viewevent.EventDispatcher import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView import expo.modules.kotlin.views.ExpoView
class GifView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { class GifView(
context: Context,
appContext: AppContext,
) : ExpoView(context, appContext) {
// Events // Events
private val onPlayerStateChange by EventDispatcher() private val onPlayerStateChange by EventDispatcher()
@@ -44,7 +46,6 @@ class GifView(context: Context, appContext: AppContext) : ExpoView(context, appC
} }
} }
// <editor-fold desc="Lifecycle"> // <editor-fold desc="Lifecycle">
init { init {
@@ -79,16 +80,19 @@ class GifView(context: Context, appContext: AppContext) : ExpoView(context, appC
return return
} }
this.webpRequest = glide.load(source) this.webpRequest =
glide
.load(source)
.diskCacheStrategy(DiskCacheStrategy.DATA) .diskCacheStrategy(DiskCacheStrategy.DATA)
.skipMemoryCache(false) .skipMemoryCache(false)
.listener(object: RequestListener<Drawable> { .listener(
object : RequestListener<Drawable> {
override fun onResourceReady( override fun onResourceReady(
resource: Drawable?, resource: Drawable?,
model: Any?, model: Any?,
target: Target<Drawable>?, target: Target<Drawable>?,
dataSource: com.bumptech.glide.load.DataSource?, dataSource: com.bumptech.glide.load.DataSource?,
isFirstResource: Boolean isFirstResource: Boolean,
): Boolean { ): Boolean {
if (placeholderRequest != null) { if (placeholderRequest != null) {
glide.clear(placeholderRequest) glide.clear(placeholderRequest)
@@ -100,25 +104,26 @@ class GifView(context: Context, appContext: AppContext) : ExpoView(context, appC
e: GlideException?, e: GlideException?,
model: Any?, model: Any?,
target: Target<Drawable>?, target: Target<Drawable>?,
isFirstResource: Boolean isFirstResource: Boolean,
): Boolean { ): Boolean = true
return true },
} ).into(this.imageView)
})
.into(this.imageView)
if (this.imageView.drawable == null || this.imageView.drawable !is Animatable) { if (this.imageView.drawable == null || this.imageView.drawable !is Animatable) {
this.placeholderRequest = glide.load(placeholderSource) this.placeholderRequest =
glide
.load(placeholderSource)
.diskCacheStrategy(DiskCacheStrategy.DATA) .diskCacheStrategy(DiskCacheStrategy.DATA)
// Let's not bloat the memory cache with placeholders // Let's not bloat the memory cache with placeholders
.skipMemoryCache(true) .skipMemoryCache(true)
.listener(object: RequestListener<Drawable> { .listener(
object : RequestListener<Drawable> {
override fun onResourceReady( override fun onResourceReady(
resource: Drawable?, resource: Drawable?,
model: Any?, model: Any?,
target: Target<Drawable>?, target: Target<Drawable>?,
dataSource: com.bumptech.glide.load.DataSource?, dataSource: com.bumptech.glide.load.DataSource?,
isFirstResource: Boolean isFirstResource: Boolean,
): Boolean { ): Boolean {
// Incase this request finishes after the webp, let's just not set // Incase this request finishes after the webp, let's just not set
// the drawable. This shouldn't happen because the request should get cancelled // the drawable. This shouldn't happen because the request should get cancelled
@@ -132,12 +137,10 @@ class GifView(context: Context, appContext: AppContext) : ExpoView(context, appC
e: GlideException?, e: GlideException?,
model: Any?, model: Any?,
target: Target<Drawable>?, target: Target<Drawable>?,
isFirstResource: Boolean isFirstResource: Boolean,
): Boolean { ): Boolean = true
return true },
} ).submit()
})
.submit()
} }
} }
@@ -170,10 +173,12 @@ class GifView(context: Context, appContext: AppContext) : ExpoView(context, appC
// <editor-fold desc="Util"> // <editor-fold desc="Util">
fun firePlayerStateChange() { fun firePlayerStateChange() {
onPlayerStateChange(mapOf( onPlayerStateChange(
mapOf(
"isPlaying" to this.isPlaying, "isPlaying" to this.isPlaying,
"isLoaded" to this.isLoaded, "isLoaded" to this.isLoaded,
)) ),
)
} }
// </editor-fold> // </editor-fold>
@@ -22,8 +22,8 @@ public class GifView: ExpoView, AVPlayerViewControllerDelegate {
private var placeholderOperation: SDWebImageCombinedOperation? private var placeholderOperation: SDWebImageCombinedOperation?
// Props // Props
var source: String? = nil var source: String?
var placeholderSource: String? = nil var placeholderSource: String?
var autoplay = true { var autoplay = true {
didSet { didSet {
if !autoplay { if !autoplay {
@@ -78,8 +78,7 @@ public class GifView: ExpoView, AVPlayerViewControllerDelegate {
// See: // See:
// https://github.com/SDWebImage/SDWebImage/blob/master/Docs/HowToUse.md#using-asynchronous-image-caching-independently // https://github.com/SDWebImage/SDWebImage/blob/master/Docs/HowToUse.md#using-asynchronous-image-caching-independently
if !SDImageCache.shared.diskImageDataExists(withKey: source), if !SDImageCache.shared.diskImageDataExists(withKey: source),
let url = URL(string: placeholderSource) let url = URL(string: placeholderSource) {
{
self.placeholderOperation = imageManager.loadImage( self.placeholderOperation = imageManager.loadImage(
with: url, with: url,
options: [.retryFailed], options: [.retryFailed],
@@ -132,8 +131,7 @@ public class GifView: ExpoView, AVPlayerViewControllerDelegate {
if let placeholderSource = self.placeholderSource, if let placeholderSource = self.placeholderSource,
imageUrl?.absoluteString == placeholderSource, imageUrl?.absoluteString == placeholderSource,
self.imageView.image == nil, self.imageView.image == nil,
let image = image let image = image {
{
self.setImage(image) self.setImage(image)
return return
} }
@@ -142,8 +140,7 @@ public class GifView: ExpoView, AVPlayerViewControllerDelegate {
imageUrl?.absoluteString == source, imageUrl?.absoluteString == source,
// UIImage perf suckssss if the image is animated // UIImage perf suckssss if the image is animated
let data = data, let data = data,
let animatedImage = SDAnimatedImage(data: data) let animatedImage = SDAnimatedImage(data: data) {
{
self.placeholderOperation?.cancel() self.placeholderOperation?.cancel()
self.isPlaying = self.autoplay self.isPlaying = self.autoplay
self.isLoaded = true self.isLoaded = true
@@ -1,19 +1,67 @@
package expo.modules.blueskyswissarmy.referrer package expo.modules.blueskyswissarmy.referrer
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.util.Log import android.util.Log
import com.android.installreferrer.api.InstallReferrerClient import com.android.installreferrer.api.InstallReferrerClient
import com.android.installreferrer.api.InstallReferrerStateListener import com.android.installreferrer.api.InstallReferrerStateListener
import expo.modules.kotlin.Promise
import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.Promise
class ExpoBlueskyReferrerModule : Module() { class ExpoBlueskyReferrerModule : Module() {
override fun definition() = ModuleDefinition { private var intent: Intent? = null
private var activityReferrer: Uri? = null
override fun definition() =
ModuleDefinition {
Name("ExpoBlueskyReferrer") Name("ExpoBlueskyReferrer")
OnNewIntent {
intent = it
activityReferrer = appContext.currentActivity?.referrer
}
Function("getReferrerInfo") {
val intentReferrer =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent?.getParcelableExtra(Intent.EXTRA_REFERRER, Uri::class.java)
} else {
intent?.getParcelableExtra(Intent.EXTRA_REFERRER)
}
// Some apps explicitly set a referrer, like Chrome. In these cases, we prefer this since
// it's the actual website that the user came from rather than the app.
if (intentReferrer is Uri) {
val res =
mapOf(
"referrer" to intentReferrer.toString(),
"hostname" to intentReferrer.host,
)
intent = null
return@Function res
}
// In all other cases, we'll just record the app that sent the intent.
if (activityReferrer != null) {
// referrer could become null here. `.toString()` though can be called on null
val res =
mapOf(
"referrer" to activityReferrer.toString(),
"hostname" to (activityReferrer?.host ?: ""),
)
activityReferrer = null
return@Function res
}
return@Function null
}
AsyncFunction("getGooglePlayReferrerInfoAsync") { promise: Promise -> AsyncFunction("getGooglePlayReferrerInfoAsync") { promise: Promise ->
val referrerClient = InstallReferrerClient.newBuilder(appContext.reactContext).build() val referrerClient = InstallReferrerClient.newBuilder(appContext.reactContext).build()
referrerClient.startConnection(object : InstallReferrerStateListener { referrerClient.startConnection(
object : InstallReferrerStateListener {
override fun onInstallReferrerSetupFinished(responseCode: Int) { override fun onInstallReferrerSetupFinished(responseCode: Int) {
if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) { if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
Log.d("ExpoGooglePlayReferrer", "Successfully retrieved referrer info.") Log.d("ExpoGooglePlayReferrer", "Successfully retrieved referrer info.")
@@ -25,15 +73,15 @@ class ExpoBlueskyReferrerModule : Module() {
mapOf( mapOf(
"installReferrer" to response.installReferrer, "installReferrer" to response.installReferrer,
"clickTimestamp" to response.referrerClickTimestampSeconds, "clickTimestamp" to response.referrerClickTimestampSeconds,
"installTimestamp" to response.installBeginTimestampSeconds "installTimestamp" to response.installBeginTimestampSeconds,
) ),
) )
} else { } else {
Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Unknown error.") Log.d("ExpoGooglePlayReferrer", "Failed to get referrer info. Unknown error.")
promise.reject( promise.reject(
"ERR_GOOGLE_PLAY_REFERRER_UNKNOWN", "ERR_GOOGLE_PLAY_REFERRER_UNKNOWN",
"Failed to get referrer info", "Failed to get referrer info",
Exception("Failed to get referrer info") Exception("Failed to get referrer info"),
) )
} }
referrerClient.endConnection() referrerClient.endConnection()
@@ -45,10 +93,11 @@ class ExpoBlueskyReferrerModule : Module() {
promise.reject( promise.reject(
"ERR_GOOGLE_PLAY_REFERRER_DISCONNECTED", "ERR_GOOGLE_PLAY_REFERRER_DISCONNECTED",
"Failed to get referrer info", "Failed to get referrer info",
Exception("Failed to get referrer info") Exception("Failed to get referrer info"),
) )
} }
}) },
)
} }
} }
} }
@@ -0,0 +1,69 @@
package expo.modules.blueskyswissarmy.sharedprefs
import android.content.Context
import android.util.Log
import expo.modules.kotlin.jni.JavaScriptValue
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoBlueskySharedPrefsModule : Module() {
private fun getContext(): Context {
val context = appContext.reactContext ?: throw Error("Context is null")
return context
}
override fun definition() =
ModuleDefinition {
Name("ExpoBlueskySharedPrefs")
Function("setString") { key: String, value: String ->
return@Function SharedPrefs(getContext()).setValue(key, value)
}
Function("setValue") { key: String, value: JavaScriptValue ->
val context = getContext()
Log.d("ExpoBlueskySharedPrefs", "Setting value for key: $key")
try {
if (value.isNumber()) {
SharedPrefs(context).setValue(key, value.getFloat())
} else if (value.isBool()) {
SharedPrefs(context).setValue(key, value.getBool())
} else if (value.isNull() || value.isUndefined()) {
SharedPrefs(context).removeValue(key)
} else {
Log.d(NAME, "Unsupported type: ${value.kind()}")
}
} catch (e: Error) {
Log.d(NAME, "Error setting value: $e")
}
}
Function("removeValue") { key: String ->
return@Function SharedPrefs(getContext()).removeValue(key)
}
Function("getString") { key: String ->
return@Function SharedPrefs(getContext()).getString(key)
}
Function("getNumber") { key: String ->
return@Function SharedPrefs(getContext()).getFloat(key)
}
Function("getBool") { key: String ->
return@Function SharedPrefs(getContext()).getBoolean(key)
}
Function("addToSet") { key: String, value: String ->
return@Function SharedPrefs(getContext()).addToSet(key, value)
}
Function("removeFromSet") { key: String, value: String ->
return@Function SharedPrefs(getContext()).removeFromSet(key, value)
}
Function("setContains") { key: String, value: String ->
return@Function SharedPrefs(getContext()).setContains(key, value)
}
}
}
@@ -4,7 +4,8 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log import android.util.Log
val DEFAULTS = mapOf<String, Any>( val DEFAULTS =
mapOf<String, Any>(
"playSoundChat" to true, "playSoundChat" to true,
"playSoundFollow" to false, "playSoundFollow" to false,
"playSoundLike" to false, "playSoundLike" to false,
@@ -17,13 +18,18 @@ val DEFAULTS = mapOf<String, Any>(
const val NAME = "SharedPrefs" const val NAME = "SharedPrefs"
class SharedPrefs(private val context: Context) { class SharedPrefs(
private val context: Context,
) {
companion object { companion object {
private var hasInitialized = false private var hasInitialized = false
private var instance: SharedPreferences? = null private var instance: SharedPreferences? = null
fun getInstance(context: Context, info: String? = "(no info)"): SharedPreferences { fun getInstance(
context: Context,
info: String? = "(no info)",
): SharedPreferences {
if (instance == null) { if (instance == null) {
Log.d(NAME, "No preferences instance found, creating one.") Log.d(NAME, "No preferences instance found, creating one.")
instance = context.getSharedPreferences("xyz.blueskyweb.app", Context.MODE_PRIVATE) instance = context.getSharedPreferences("xyz.blueskyweb.app", Context.MODE_PRIVATE)
@@ -68,42 +74,63 @@ class SharedPrefs(private val context: Context) {
} }
} }
} }
} }.apply()
.apply()
} }
} }
fun setValue(key: String, value: String) { fun setValue(
key: String,
value: String,
) {
val safeInstance = getInstance(context) val safeInstance = getInstance(context)
safeInstance.edit().apply { safeInstance
.edit()
.apply {
putString(key, value) putString(key, value)
}.apply() }.apply()
} }
fun setValue(key: String, value: Float) { fun setValue(
key: String,
value: Float,
) {
val safeInstance = getInstance(context) val safeInstance = getInstance(context)
safeInstance.edit().apply { safeInstance
.edit()
.apply {
putFloat(key, value) putFloat(key, value)
}.apply() }.apply()
} }
fun setValue(key: String, value: Boolean) { fun setValue(
key: String,
value: Boolean,
) {
val safeInstance = getInstance(context) val safeInstance = getInstance(context)
safeInstance.edit().apply { safeInstance
.edit()
.apply {
putBoolean(key, value) putBoolean(key, value)
}.apply() }.apply()
} }
fun setValue(key: String, value: Set<String>) { fun setValue(
key: String,
value: Set<String>,
) {
val safeInstance = getInstance(context) val safeInstance = getInstance(context)
safeInstance.edit().apply { safeInstance
.edit()
.apply {
putStringSet(key, value) putStringSet(key, value)
}.apply() }.apply()
} }
fun removeValue(key: String) { fun removeValue(key: String) {
val safeInstance = getInstance(context) val safeInstance = getInstance(context)
safeInstance.edit().apply { safeInstance
.edit()
.apply {
remove(key) remove(key)
}.apply() }.apply()
} }
@@ -121,9 +148,15 @@ class SharedPrefs(private val context: Context) {
return safeInstance.getFloat(key, 0.0f) return safeInstance.getFloat(key, 0.0f)
} }
fun _setAnyValue(key: String, value: Any) { @Suppress("ktlint:standard:function-naming")
fun _setAnyValue(
key: String,
value: Any,
) {
val safeInstance = getInstance(context) val safeInstance = getInstance(context)
safeInstance.edit().apply { safeInstance
.edit()
.apply {
when (value) { when (value) {
is String -> putString(key, value) is String -> putString(key, value)
is Float -> putFloat(key, value) is Float -> putFloat(key, value)
@@ -145,29 +178,44 @@ class SharedPrefs(private val context: Context) {
return res return res
} }
fun addToSet(key: String, value: String) { fun addToSet(
key: String,
value: String,
) {
val safeInstance = getInstance(context) val safeInstance = getInstance(context)
val set = safeInstance.getStringSet(key, setOf()) ?: setOf() val set = safeInstance.getStringSet(key, setOf()) ?: setOf()
val newSet = set.toMutableSet().apply { val newSet =
set.toMutableSet().apply {
add(value) add(value)
} }
safeInstance.edit().apply { safeInstance
.edit()
.apply {
putStringSet(key, newSet) putStringSet(key, newSet)
}.apply() }.apply()
} }
fun removeFromSet(key: String, value: String) { fun removeFromSet(
key: String,
value: String,
) {
val safeInstance = getInstance(context) val safeInstance = getInstance(context)
val set = safeInstance.getStringSet(key, setOf()) ?: setOf() val set = safeInstance.getStringSet(key, setOf()) ?: setOf()
val newSet = set.toMutableSet().apply { val newSet =
set.toMutableSet().apply {
remove(value) remove(value)
} }
safeInstance.edit().apply { safeInstance
.edit()
.apply {
putStringSet(key, newSet) putStringSet(key, newSet)
}.apply() }.apply()
} }
fun setContains(key: String, value: String): Boolean { fun setContains(
key: String,
value: String,
): Boolean {
val safeInstance = getInstance(context) val safeInstance = getInstance(context)
val set = safeInstance.getStringSet(key, setOf()) ?: setOf() val set = safeInstance.getStringSet(key, setOf()) ?: setOf()
return set.contains(value) return set.contains(value)
@@ -1,9 +1,13 @@
import {requireNativeModule} from 'expo' import {requireNativeModule} from 'expo'
import {GooglePlayReferrerInfo} from './types' import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
export const NativeModule = requireNativeModule('ExpoBlueskyReferrer') export const NativeModule = requireNativeModule('ExpoBlueskyReferrer')
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> { export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo | null> {
return NativeModule.getGooglePlayReferrerInfoAsync() return NativeModule.getGooglePlayReferrerInfoAsync()
} }
export function getReferrerInfo(): Promise<ReferrerInfo | null> {
return NativeModule.getReferrerInfo()
}
@@ -0,0 +1,37 @@
import {SharedPrefs} from '../../index'
import {NotImplementedError} from '../NotImplemented'
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
throw new NotImplementedError()
}
export function getReferrerInfo(): ReferrerInfo | null {
const referrer = SharedPrefs.getString('referrer')
if (referrer) {
SharedPrefs.removeValue('referrer')
try {
const url = new URL(referrer)
return {
referrer,
hostname: url.hostname,
}
} catch (e) {
return {
referrer,
hostname: referrer,
}
}
}
const referrerApp = SharedPrefs.getString('referrerApp')
if (referrerApp) {
SharedPrefs.removeValue('referrerApp')
return {
referrer: referrerApp,
hostname: referrerApp,
}
}
return null
}
@@ -1,7 +1,10 @@
import {NotImplementedError} from '../NotImplemented' import {NotImplementedError} from '../NotImplemented'
import {GooglePlayReferrerInfo} from './types' import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
// @ts-ignore throws
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> { export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
throw new NotImplementedError() throw new NotImplementedError()
} }
export function getReferrerInfo(): ReferrerInfo | null {
throw new NotImplementedError()
}
@@ -0,0 +1,33 @@
import {Platform} from 'react-native'
import {NotImplementedError} from '../NotImplemented'
import {GooglePlayReferrerInfo, ReferrerInfo} from './types'
export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo> {
throw new NotImplementedError()
}
export function getReferrerInfo(): ReferrerInfo | null {
if (
Platform.OS === 'web' &&
// for ssr
typeof document !== 'undefined' &&
document != null &&
document.referrer
) {
try {
const url = new URL(document.referrer)
if (url.hostname !== 'bsky.app') {
return {
referrer: url.href,
hostname: url.hostname,
}
}
} catch {
// If something happens to the URL parsing, we don't want to actually cause any problems for the user. Just
// log the error so we might catch it
console.error('Failed to parse referrer URL')
}
}
return null
}
@@ -1,7 +1,10 @@
export type GooglePlayReferrerInfo = export type GooglePlayReferrerInfo = {
| {
installReferrer?: string installReferrer?: string
clickTimestamp?: number clickTimestamp?: number
installTimestamp?: number installTimestamp?: number
} }
| undefined
export type ReferrerInfo = {
referrer: string
hostname: string
}
@@ -44,7 +44,8 @@ export function setContains(key: string, value: string): boolean {
// iOS returns `null` if a value does not exist, and Android returns `undefined. Normalize these here for JS types // iOS returns `null` if a value does not exist, and Android returns `undefined. Normalize these here for JS types
function nullToUndefined(value: any) { function nullToUndefined(value: any) {
if (value === null) { if (value == null) {
return undefined return undefined
} }
return value
} }
@@ -13,7 +13,8 @@ import java.io.FileOutputStream
import java.net.URLEncoder import java.net.URLEncoder
class ExpoReceiveAndroidIntentsModule : Module() { class ExpoReceiveAndroidIntentsModule : Module() {
override fun definition() = ModuleDefinition { override fun definition() =
ModuleDefinition {
Name("ExpoReceiveAndroidIntents") Name("ExpoReceiveAndroidIntents")
OnNewIntent { OnNewIntent {
@@ -40,7 +41,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun handleTextIntent(intent: Intent) { private fun handleTextIntent(intent: Intent) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.let { intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
val encoded = URLEncoder.encode(it, "UTF-8") val encoded = URLEncoder.encode(it, "UTF-8")
"bluesky://intent/compose?text=${encoded}".toUri().let { uri -> "bluesky://intent/compose?text=$encoded".toUri().let { uri ->
val newIntent = Intent(Intent.ACTION_VIEW, uri) val newIntent = Intent(Intent.ACTION_VIEW, uri)
appContext.currentActivity?.startActivity(newIntent) appContext.currentActivity?.startActivity(newIntent)
} }
@@ -48,7 +49,8 @@ class ExpoReceiveAndroidIntentsModule : Module() {
} }
private fun handleImageIntent(intent: Intent) { private fun handleImageIntent(intent: Intent) {
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val uri =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else { } else {
intent.getParcelableExtra(Intent.EXTRA_STREAM) intent.getParcelableExtra(Intent.EXTRA_STREAM)
@@ -76,16 +78,16 @@ class ExpoReceiveAndroidIntentsModule : Module() {
uris.forEachIndexed { index, uri -> uris.forEachIndexed { index, uri ->
val info = getImageInfo(uri) val info = getImageInfo(uri)
val params = buildUriData(info) val params = buildUriData(info)
allParams = "${allParams}${params}" allParams = "${allParams}$params"
if (index < uris.count() - 1) { if (index < uris.count() - 1) {
allParams = "${allParams}," allParams = "$allParams,"
} }
} }
val encoded = URLEncoder.encode(allParams, "UTF-8") val encoded = URLEncoder.encode(allParams, "UTF-8")
"bluesky://intent/compose?imageUris=${encoded}".toUri().let { "bluesky://intent/compose?imageUris=$encoded".toUri().let {
val newIntent = Intent(Intent.ACTION_VIEW, it) val newIntent = Intent(Intent.ACTION_VIEW, it)
appContext.currentActivity?.startActivity(newIntent) appContext.currentActivity?.startActivity(newIntent)
} }
@@ -104,7 +106,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
return mapOf( return mapOf(
"width" to bitmap.width, "width" to bitmap.width,
"height" to bitmap.height, "height" to bitmap.height,
"path" to file.path.toString() "path" to file.path.toString(),
) )
} }
@@ -114,6 +116,6 @@ class ExpoReceiveAndroidIntentsModule : Module() {
val path = info.getValue("path") val path = info.getValue("path")
val width = info.getValue("width") val width = info.getValue("width")
val height = info.getValue("height") val height = info.getValue("height")
return "file://${path}|${width}|${height}" return "file://$path|$width|$height"
} }
} }
@@ -35,7 +35,6 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
self.cancelGestureRecognizers = [lpg, tg] self.cancelGestureRecognizers = [lpg, tg]
} }
// We don't want to recognize the scroll pan gesture and the swipe back gesture together // We don't want to recognize the scroll pan gesture and the swipe back gesture together
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UIPanGestureRecognizer, otherGestureRecognizer is UIPanGestureRecognizer { if gestureRecognizer is UIPanGestureRecognizer, otherGestureRecognizer is UIPanGestureRecognizer {
@@ -64,11 +63,11 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
// This will be used to cancel the animation whenever we press inside of the scroll view. We don't want to change // This will be used to cancel the animation whenever we press inside of the scroll view. We don't want to change
// the scroll view gesture's delegate, so we add an additional recognizer to detect this. // the scroll view gesture's delegate, so we add an additional recognizer to detect this.
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void { @IBAction func callOnPress(_ sender: UITapGestureRecognizer) {
self.stopTimer() self.stopTimer()
} }
@IBAction func callOnPan(_ sender: UIPanGestureRecognizer) -> Void { @IBAction func callOnPan(_ sender: UIPanGestureRecognizer) {
guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else { guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else {
return return
} }
@@ -129,7 +128,7 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
} }
var animTranslation = -translation var animTranslation = -translation
self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { timer in self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { _ in
velocity *= 0.9875 velocity *= 0.9875
animTranslation = (-velocity / 120) + animTranslation animTranslation = (-velocity / 120) + animTranslation
@@ -190,7 +189,6 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
} }
} }
func enableCancelGestureRecognizers() { func enableCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in self.cancelGestureRecognizers?.forEach { r in
r.isEnabled = true r.isEnabled = true
@@ -203,11 +201,11 @@ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
} }
} }
func scrollToOffset(_ offset: Int, animated: Bool = true) -> Void { func scrollToOffset(_ offset: Int, animated: Bool = true) {
self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated) self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated)
} }
func stopTimer() -> Void { func stopTimer() {
self.disableCancelGestureRecognizers() self.disableCancelGestureRecognizers()
self.animTimer?.invalidate() self.animTimer?.invalidate()
self.animTimer = nil self.animTimer = nil
+7 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.87.0", "version": "1.89.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -29,6 +29,8 @@
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit", "test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
"test-coverage": "NODE_ENV=test jest --coverage", "test-coverage": "NODE_ENV=test jest --coverage",
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src", "lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src",
"lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"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 --project tsconfig.e2e.json __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": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios", "e2e:metro": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
@@ -50,7 +52,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.12.22", "@atproto/api": "^0.12.23",
"@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",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -137,6 +139,7 @@
"expo-system-ui": "~3.0.4", "expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.1", "expo-task-manager": "~11.8.1",
"expo-updates": "~0.25.14", "expo-updates": "~0.25.14",
"expo-video": "^1.1.10",
"expo-web-browser": "~13.0.3", "expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6", "fast-text-encoding": "^1.0.6",
"history": "^5.3.0", "history": "^5.3.0",
@@ -167,6 +170,7 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-keyed-flatten-children": "^3.0.0", "react-keyed-flatten-children": "^3.0.0",
"react-native": "0.74.1", "react-native": "0.74.1",
"react-native-compressor": "^1.8.24",
"react-native-date-picker": "^4.4.2", "react-native-date-picker": "^4.4.2",
"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",
@@ -194,6 +198,7 @@
"react-responsive": "^9.0.2", "react-responsive": "^9.0.2",
"react-textarea-autosize": "^8.5.3", "react-textarea-autosize": "^8.5.3",
"rn-fetch-blob": "^0.12.0", "rn-fetch-blob": "^0.12.0",
"rn-tourguide": "bluesky-social/rn-tourguide",
"sentry-expo": "~7.0.1", "sentry-expo": "~7.0.1",
"statsig-react-native-expo": "^4.6.1", "statsig-react-native-expo": "^4.6.1",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
+41
View File
@@ -0,0 +1,41 @@
const {withAppDelegate} = require('@expo/config-plugins')
const {mergeContents} = require('@expo/config-plugins/build/utils/generateCode')
const path = require('path')
const fs = require('fs')
module.exports = config => {
// eslint-disable-next-line no-shadow
return withAppDelegate(config, async config => {
const delegatePath = path.join(
config.modRequest.platformProjectRoot,
'AppDelegate.mm',
)
let newContents = config.modResults.contents
newContents = mergeContents({
src: newContents,
anchor: '// Linking API',
newSrc: `
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
[defaults setObject:options[UIApplicationOpenURLOptionsSourceApplicationKey] forKey:@"referrerApp"];\n`,
offset: 2,
tag: 'referrer info - deep links',
comment: '//',
}).contents
newContents = mergeContents({
src: newContents,
anchor: '// Universal Links',
newSrc: `
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
[defaults setURL:userActivity.referrerURL forKey:@"referrer"];\n`,
offset: 2,
tag: 'referrer info - universal links',
comment: '//',
}).contents
config.modResults.contents = newContents
return config
})
}
+8 -1
View File
@@ -45,6 +45,7 @@ import {
import {readLastActiveAccount} from '#/state/session/util' import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {TestCtrls} from '#/view/com/testing/TestCtrls' import {TestCtrls} from '#/view/com/testing/TestCtrls'
@@ -55,6 +56,7 @@ import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as PortalProvider} from '#/components/Portal' import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash' import {Splash} from '#/Splash'
import {Provider as TourProvider} from '#/tours'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import I18nProvider from './locale/i18nProvider' import I18nProvider from './locale/i18nProvider'
import {listenSessionDropped} from './state/events' import {listenSessionDropped} from './state/events'
@@ -117,10 +119,15 @@ function InnerApp() {
<UnreadNotifsProvider> <UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider> <BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider> <MutedThreadsProvider>
<GestureHandlerRootView style={s.h100pct}> <TourProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls /> <TestCtrls />
<Shell /> <Shell />
</GestureHandlerRootView> </GestureHandlerRootView>
</ProgressGuideProvider>
</TourProvider>
</MutedThreadsProvider> </MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider> </BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider> </UnreadNotifsProvider>
+6
View File
@@ -34,6 +34,7 @@ import {
import {readLastActiveAccount} from '#/state/session/util' import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
@@ -43,6 +44,7 @@ import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as PortalProvider} from '#/components/Portal' import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as TourProvider} from '#/tours'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import I18nProvider from './locale/i18nProvider' import I18nProvider from './locale/i18nProvider'
import {listenSessionDropped} from './state/events' import {listenSessionDropped} from './state/events'
@@ -102,7 +104,11 @@ function InnerApp() {
<BackgroundNotificationPreferencesProvider> <BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider> <MutedThreadsProvider>
<SafeAreaProvider> <SafeAreaProvider>
<TourProvider>
<ProgressGuideProvider>
<Shell /> <Shell />
</ProgressGuideProvider>
</TourProvider>
</SafeAreaProvider> </SafeAreaProvider>
</MutedThreadsProvider> </MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider> </BackgroundNotificationPreferencesProvider>
+23 -3
View File
@@ -31,7 +31,7 @@ import {
} from 'lib/routes/types' } from 'lib/routes/types'
import {RouteParams, State} from 'lib/routes/types' import {RouteParams, State} from 'lib/routes/types'
import {bskyTitle} from 'lib/strings/headings' import {bskyTitle} from 'lib/strings/headings'
import {isAndroid, isNative} from 'platform/detection' import {isAndroid, isNative, isWeb} from 'platform/detection'
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds' import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
import {AppPasswords} from 'view/screens/AppPasswords' import {AppPasswords} from 'view/screens/AppPasswords'
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts' import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
@@ -44,8 +44,12 @@ import HashtagScreen from '#/screens/Hashtag'
import {ModerationScreen} from '#/screens/Moderation' import {ModerationScreen} from '#/screens/Moderation'
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {StarterPackScreen} from '#/screens/StarterPack/StarterPackScreen' import {
StarterPackScreen,
StarterPackScreenShort,
} from '#/screens/StarterPack/StarterPackScreen'
import {Wizard} from '#/screens/StarterPack/Wizard' import {Wizard} from '#/screens/StarterPack/Wizard'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {init as initAnalytics} from './lib/analytics/analytics' import {init as initAnalytics} from './lib/analytics/analytics'
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig' import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
@@ -328,7 +332,12 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen <Stack.Screen
name="StarterPack" name="StarterPack"
getComponent={() => StarterPackScreen} getComponent={() => StarterPackScreen}
options={{title: title(msg`Starter Pack`), requireAuth: true}} options={{title: title(msg`Starter Pack`)}}
/>
<Stack.Screen
name="StarterPackShort"
getComponent={() => StarterPackScreenShort}
options={{title: title(msg`Starter Pack`)}}
/> />
<Stack.Screen <Stack.Screen
name="StarterPackWizard" name="StarterPackWizard"
@@ -761,6 +770,17 @@ function logModuleInitTime() {
initMs, initMs,
}) })
if (isWeb) {
const referrerInfo = Referrer.getReferrerInfo()
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
logEvent('deepLink:referrerReceived', {
to: window.location.href,
referrer: referrerInfo?.referrer,
hostname: referrerInfo?.hostname,
})
}
}
if (__DEV__) { if (__DEV__) {
// This log is noisy, so keep false committed // This log is noisy, so keep false committed
const shouldLog = false const shouldLog = false
+16 -5
View File
@@ -1,7 +1,9 @@
import React from 'react' import React from 'react'
import {Dimensions} from 'react-native' import {Dimensions} from 'react-native'
import * as themes from '#/alf/themes' import {createThemes, defaultTheme} from '#/alf/themes'
import {Theme, ThemeName} from '#/alf/types'
import {BLUE_HUE, GREEN_HUE, RED_HUE} from '#/alf/util/colorGeneration'
export {atoms} from '#/alf/atoms' export {atoms} from '#/alf/atoms'
export * as tokens from '#/alf/tokens' export * as tokens from '#/alf/tokens'
@@ -39,8 +41,8 @@ function getActiveBreakpoints({width}: {width: number}) {
* Context * Context
*/ */
export const Context = React.createContext<{ export const Context = React.createContext<{
themeName: themes.ThemeName themeName: ThemeName
theme: themes.Theme theme: Theme
breakpoints: { breakpoints: {
active: BreakpointName | undefined active: BreakpointName | undefined
gtPhone: boolean gtPhone: boolean
@@ -49,7 +51,7 @@ export const Context = React.createContext<{
} }
}>({ }>({
themeName: 'light', themeName: 'light',
theme: themes.light, theme: defaultTheme,
breakpoints: { breakpoints: {
active: undefined, active: undefined,
gtPhone: false, gtPhone: false,
@@ -61,7 +63,16 @@ export const Context = React.createContext<{
export function ThemeProvider({ export function ThemeProvider({
children, children,
theme: themeName, theme: themeName,
}: React.PropsWithChildren<{theme: themes.ThemeName}>) { }: React.PropsWithChildren<{theme: ThemeName}>) {
const themes = React.useMemo(() => {
return createThemes({
hues: {
primary: BLUE_HUE,
negative: RED_HUE,
positive: GREEN_HUE,
},
})
}, [])
const theme = themes[themeName] const theme = themes[themeName]
const [breakpoints, setBreakpoints] = React.useState(() => const [breakpoints, setBreakpoints] = React.useState(() =>
getActiveBreakpoints({width: Dimensions.get('window').width}), getActiveBreakpoints({width: Dimensions.get('window').width}),
+306 -184
View File
@@ -1,199 +1,311 @@
import {atoms} from '#/alf/atoms' import {atoms} from '#/alf/atoms'
import * as tokens from '#/alf/tokens' import {Palette, Theme} from '#/alf/types'
import type {Mutable} from '#/alf/types' import {
import {BLUE_HUE, GREEN_HUE, RED_HUE} from '#/alf/util/colorGeneration' BLUE_HUE,
defaultScale,
dimScale,
GREEN_HUE,
RED_HUE,
} from '#/alf/util/colorGeneration'
export type ThemeName = 'light' | 'dim' | 'dark' const themes = createThemes({
export type ReadonlyTheme = typeof light hues: {
export type Theme = Mutable<ReadonlyTheme> & {name: ThemeName} primary: BLUE_HUE,
export type ReadonlyPalette = typeof lightPalette negative: RED_HUE,
export type Palette = Mutable<ReadonlyPalette> positive: GREEN_HUE,
},
})
export const lightPalette = { /**
white: tokens.color.gray_0, * @deprecated use ALF and access palette from `useTheme()`
black: tokens.color.gray_1000, */
export const lightPalette = themes.lightPalette
/**
* @deprecated use ALF and access palette from `useTheme()`
*/
export const darkPalette = themes.darkPalette
/**
* @deprecated use ALF and access palette from `useTheme()`
*/
export const dimPalette = themes.dimPalette
/**
* @deprecated use ALF and access theme from `useTheme()`
*/
export const light = themes.light
/**
* @deprecated use ALF and access theme from `useTheme()`
*/
export const dark = themes.dark
/**
* @deprecated use ALF and access theme from `useTheme()`
*/
export const dim = themes.dim
contrast_25: tokens.color.gray_25, export const defaultTheme = themes.light
contrast_50: tokens.color.gray_50,
contrast_100: tokens.color.gray_100,
contrast_200: tokens.color.gray_200,
contrast_300: tokens.color.gray_300,
contrast_400: tokens.color.gray_400,
contrast_500: tokens.color.gray_500,
contrast_600: tokens.color.gray_600,
contrast_700: tokens.color.gray_700,
contrast_800: tokens.color.gray_800,
contrast_900: tokens.color.gray_900,
contrast_950: tokens.color.gray_950,
contrast_975: tokens.color.gray_975,
primary_25: tokens.color.blue_25, export function createThemes({
primary_50: tokens.color.blue_50, hues,
primary_100: tokens.color.blue_100, }: {
primary_200: tokens.color.blue_200, hues: {
primary_300: tokens.color.blue_300, primary: number
primary_400: tokens.color.blue_400, negative: number
primary_500: tokens.color.blue_500, positive: number
primary_600: tokens.color.blue_600, }
primary_700: tokens.color.blue_700, }): {
primary_800: tokens.color.blue_800, lightPalette: Palette
primary_900: tokens.color.blue_900, darkPalette: Palette
primary_950: tokens.color.blue_950, dimPalette: Palette
primary_975: tokens.color.blue_975, light: Theme
dark: Theme
dim: Theme
} {
const color = {
trueBlack: '#000000',
positive_25: tokens.color.green_25, gray_0: `hsl(${hues.primary}, 20%, ${defaultScale[14]}%)`,
positive_50: tokens.color.green_50, gray_25: `hsl(${hues.primary}, 20%, ${defaultScale[13]}%)`,
positive_100: tokens.color.green_100, gray_50: `hsl(${hues.primary}, 20%, ${defaultScale[12]}%)`,
positive_200: tokens.color.green_200, gray_100: `hsl(${hues.primary}, 20%, ${defaultScale[11]}%)`,
positive_300: tokens.color.green_300, gray_200: `hsl(${hues.primary}, 20%, ${defaultScale[10]}%)`,
positive_400: tokens.color.green_400, gray_300: `hsl(${hues.primary}, 20%, ${defaultScale[9]}%)`,
positive_500: tokens.color.green_500, gray_400: `hsl(${hues.primary}, 20%, ${defaultScale[8]}%)`,
positive_600: tokens.color.green_600, gray_500: `hsl(${hues.primary}, 20%, ${defaultScale[7]}%)`,
positive_700: tokens.color.green_700, gray_600: `hsl(${hues.primary}, 24%, ${defaultScale[6]}%)`,
positive_800: tokens.color.green_800, gray_700: `hsl(${hues.primary}, 24%, ${defaultScale[5]}%)`,
positive_900: tokens.color.green_900, gray_800: `hsl(${hues.primary}, 28%, ${defaultScale[4]}%)`,
positive_950: tokens.color.green_950, gray_900: `hsl(${hues.primary}, 28%, ${defaultScale[3]}%)`,
positive_975: tokens.color.green_975, gray_950: `hsl(${hues.primary}, 28%, ${defaultScale[2]}%)`,
gray_975: `hsl(${hues.primary}, 28%, ${defaultScale[1]}%)`,
gray_1000: `hsl(${hues.primary}, 28%, ${defaultScale[0]}%)`,
negative_25: tokens.color.red_25, primary_25: `hsl(${hues.primary}, 99%, 97%)`,
negative_50: tokens.color.red_50, primary_50: `hsl(${hues.primary}, 99%, 95%)`,
negative_100: tokens.color.red_100, primary_100: `hsl(${hues.primary}, 99%, 90%)`,
negative_200: tokens.color.red_200, primary_200: `hsl(${hues.primary}, 99%, 80%)`,
negative_300: tokens.color.red_300, primary_300: `hsl(${hues.primary}, 99%, 70%)`,
negative_400: tokens.color.red_400, primary_400: `hsl(${hues.primary}, 99%, 60%)`,
negative_500: tokens.color.red_500, primary_500: `hsl(${hues.primary}, 99%, 53%)`,
negative_600: tokens.color.red_600, primary_600: `hsl(${hues.primary}, 99%, 42%)`,
negative_700: tokens.color.red_700, primary_700: `hsl(${hues.primary}, 99%, 34%)`,
negative_800: tokens.color.red_800, primary_800: `hsl(${hues.primary}, 99%, 26%)`,
negative_900: tokens.color.red_900, primary_900: `hsl(${hues.primary}, 99%, 18%)`,
negative_950: tokens.color.red_950, primary_950: `hsl(${hues.primary}, 99%, 10%)`,
negative_975: tokens.color.red_975, primary_975: `hsl(${hues.primary}, 99%, 7%)`,
green_25: `hsl(${hues.positive}, 82%, 97%)`,
green_50: `hsl(${hues.positive}, 82%, 95%)`,
green_100: `hsl(${hues.positive}, 82%, 90%)`,
green_200: `hsl(${hues.positive}, 82%, 80%)`,
green_300: `hsl(${hues.positive}, 82%, 70%)`,
green_400: `hsl(${hues.positive}, 82%, 60%)`,
green_500: `hsl(${hues.positive}, 82%, 50%)`,
green_600: `hsl(${hues.positive}, 82%, 42%)`,
green_700: `hsl(${hues.positive}, 82%, 34%)`,
green_800: `hsl(${hues.positive}, 82%, 26%)`,
green_900: `hsl(${hues.positive}, 82%, 18%)`,
green_950: `hsl(${hues.positive}, 82%, 10%)`,
green_975: `hsl(${hues.positive}, 82%, 7%)`,
red_25: `hsl(${hues.negative}, 91%, 97%)`,
red_50: `hsl(${hues.negative}, 91%, 95%)`,
red_100: `hsl(${hues.negative}, 91%, 90%)`,
red_200: `hsl(${hues.negative}, 91%, 80%)`,
red_300: `hsl(${hues.negative}, 91%, 70%)`,
red_400: `hsl(${hues.negative}, 91%, 60%)`,
red_500: `hsl(${hues.negative}, 91%, 50%)`,
red_600: `hsl(${hues.negative}, 91%, 42%)`,
red_700: `hsl(${hues.negative}, 91%, 34%)`,
red_800: `hsl(${hues.negative}, 91%, 26%)`,
red_900: `hsl(${hues.negative}, 91%, 18%)`,
red_950: `hsl(${hues.negative}, 91%, 10%)`,
red_975: `hsl(${hues.negative}, 91%, 7%)`,
} as const } as const
export const darkPalette: Palette = { const lightPalette = {
white: tokens.color.gray_0, white: color.gray_0,
black: tokens.color.trueBlack, black: color.gray_1000,
contrast_25: tokens.color.gray_1000, contrast_25: color.gray_25,
contrast_50: tokens.color.gray_975, contrast_50: color.gray_50,
contrast_100: tokens.color.gray_950, contrast_100: color.gray_100,
contrast_200: tokens.color.gray_900, contrast_200: color.gray_200,
contrast_300: tokens.color.gray_800, contrast_300: color.gray_300,
contrast_400: tokens.color.gray_700, contrast_400: color.gray_400,
contrast_500: tokens.color.gray_600, contrast_500: color.gray_500,
contrast_600: tokens.color.gray_500, contrast_600: color.gray_600,
contrast_700: tokens.color.gray_400, contrast_700: color.gray_700,
contrast_800: tokens.color.gray_300, contrast_800: color.gray_800,
contrast_900: tokens.color.gray_200, contrast_900: color.gray_900,
contrast_950: tokens.color.gray_100, contrast_950: color.gray_950,
contrast_975: tokens.color.gray_50, contrast_975: color.gray_975,
primary_25: tokens.color.blue_25, primary_25: color.primary_25,
primary_50: tokens.color.blue_50, primary_50: color.primary_50,
primary_100: tokens.color.blue_100, primary_100: color.primary_100,
primary_200: tokens.color.blue_200, primary_200: color.primary_200,
primary_300: tokens.color.blue_300, primary_300: color.primary_300,
primary_400: tokens.color.blue_400, primary_400: color.primary_400,
primary_500: tokens.color.blue_500, primary_500: color.primary_500,
primary_600: tokens.color.blue_600, primary_600: color.primary_600,
primary_700: tokens.color.blue_700, primary_700: color.primary_700,
primary_800: tokens.color.blue_800, primary_800: color.primary_800,
primary_900: tokens.color.blue_900, primary_900: color.primary_900,
primary_950: tokens.color.blue_950, primary_950: color.primary_950,
primary_975: tokens.color.blue_975, primary_975: color.primary_975,
positive_25: tokens.color.green_25, positive_25: color.green_25,
positive_50: tokens.color.green_50, positive_50: color.green_50,
positive_100: tokens.color.green_100, positive_100: color.green_100,
positive_200: tokens.color.green_200, positive_200: color.green_200,
positive_300: tokens.color.green_300, positive_300: color.green_300,
positive_400: tokens.color.green_400, positive_400: color.green_400,
positive_500: tokens.color.green_500, positive_500: color.green_500,
positive_600: tokens.color.green_600, positive_600: color.green_600,
positive_700: tokens.color.green_700, positive_700: color.green_700,
positive_800: tokens.color.green_800, positive_800: color.green_800,
positive_900: tokens.color.green_900, positive_900: color.green_900,
positive_950: tokens.color.green_950, positive_950: color.green_950,
positive_975: tokens.color.green_975, positive_975: color.green_975,
negative_25: tokens.color.red_25, negative_25: color.red_25,
negative_50: tokens.color.red_50, negative_50: color.red_50,
negative_100: tokens.color.red_100, negative_100: color.red_100,
negative_200: tokens.color.red_200, negative_200: color.red_200,
negative_300: tokens.color.red_300, negative_300: color.red_300,
negative_400: tokens.color.red_400, negative_400: color.red_400,
negative_500: tokens.color.red_500, negative_500: color.red_500,
negative_600: tokens.color.red_600, negative_600: color.red_600,
negative_700: tokens.color.red_700, negative_700: color.red_700,
negative_800: tokens.color.red_800, negative_800: color.red_800,
negative_900: tokens.color.red_900, negative_900: color.red_900,
negative_950: tokens.color.red_950, negative_950: color.red_950,
negative_975: tokens.color.red_975, negative_975: color.red_975,
} as const } as const
export const dimPalette: Palette = { const darkPalette: Palette = {
white: color.gray_0,
black: color.trueBlack,
contrast_25: color.gray_1000,
contrast_50: color.gray_975,
contrast_100: color.gray_950,
contrast_200: color.gray_900,
contrast_300: color.gray_800,
contrast_400: color.gray_700,
contrast_500: color.gray_600,
contrast_600: color.gray_500,
contrast_700: color.gray_400,
contrast_800: color.gray_300,
contrast_900: color.gray_200,
contrast_950: color.gray_100,
contrast_975: color.gray_50,
primary_25: color.primary_975,
primary_50: color.primary_950,
primary_100: color.primary_900,
primary_200: color.primary_800,
primary_300: color.primary_700,
primary_400: color.primary_600,
primary_500: color.primary_500,
primary_600: color.primary_400,
primary_700: color.primary_300,
primary_800: color.primary_200,
primary_900: color.primary_100,
primary_950: color.primary_50,
primary_975: color.primary_25,
positive_25: color.green_975,
positive_50: color.green_950,
positive_100: color.green_900,
positive_200: color.green_800,
positive_300: color.green_700,
positive_400: color.green_600,
positive_500: color.green_500,
positive_600: color.green_400,
positive_700: color.green_300,
positive_800: color.green_200,
positive_900: color.green_100,
positive_950: color.green_50,
positive_975: color.green_25,
negative_25: color.red_975,
negative_50: color.red_950,
negative_100: color.red_900,
negative_200: color.red_800,
negative_300: color.red_700,
negative_400: color.red_600,
negative_500: color.red_500,
negative_600: color.red_400,
negative_700: color.red_300,
negative_800: color.red_200,
negative_900: color.red_100,
negative_950: color.red_50,
negative_975: color.red_25,
} as const
const dimPalette: Palette = {
...darkPalette, ...darkPalette,
black: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[0]}%)`, black: `hsl(${hues.primary}, 28%, ${dimScale[0]}%)`,
contrast_25: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[1]}%)`, contrast_25: `hsl(${hues.primary}, 28%, ${dimScale[1]}%)`,
contrast_50: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[2]}%)`, contrast_50: `hsl(${hues.primary}, 28%, ${dimScale[2]}%)`,
contrast_100: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[3]}%)`, contrast_100: `hsl(${hues.primary}, 28%, ${dimScale[3]}%)`,
contrast_200: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[4]}%)`, contrast_200: `hsl(${hues.primary}, 28%, ${dimScale[4]}%)`,
contrast_300: `hsl(${BLUE_HUE}, 24%, ${tokens.dimScale[5]}%)`, contrast_300: `hsl(${hues.primary}, 24%, ${dimScale[5]}%)`,
contrast_400: `hsl(${BLUE_HUE}, 24%, ${tokens.dimScale[6]}%)`, contrast_400: `hsl(${hues.primary}, 24%, ${dimScale[6]}%)`,
contrast_500: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[7]}%)`, contrast_500: `hsl(${hues.primary}, 20%, ${dimScale[7]}%)`,
contrast_600: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[8]}%)`, contrast_600: `hsl(${hues.primary}, 20%, ${dimScale[8]}%)`,
contrast_700: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[9]}%)`, contrast_700: `hsl(${hues.primary}, 20%, ${dimScale[9]}%)`,
contrast_800: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[10]}%)`, contrast_800: `hsl(${hues.primary}, 20%, ${dimScale[10]}%)`,
contrast_900: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[11]}%)`, contrast_900: `hsl(${hues.primary}, 20%, ${dimScale[11]}%)`,
contrast_950: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[12]}%)`, contrast_950: `hsl(${hues.primary}, 20%, ${dimScale[12]}%)`,
contrast_975: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[13]}%)`, contrast_975: `hsl(${hues.primary}, 20%, ${dimScale[13]}%)`,
primary_25: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[13]}%)`, primary_25: `hsl(${hues.primary}, 50%, ${dimScale[1]}%)`,
primary_50: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[12]}%)`, primary_50: `hsl(${hues.primary}, 60%, ${dimScale[2]}%)`,
primary_100: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[11]}%)`, primary_100: `hsl(${hues.primary}, 70%, ${dimScale[3]}%)`,
primary_200: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[10]}%)`, primary_200: `hsl(${hues.primary}, 82%, ${dimScale[4]}%)`,
primary_300: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[9]}%)`, primary_300: `hsl(${hues.primary}, 90%, ${dimScale[5]}%)`,
primary_400: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[8]}%)`, primary_400: `hsl(${hues.primary}, 95%, ${dimScale[6]}%)`,
primary_500: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[7]}%)`, primary_500: `hsl(${hues.primary}, 99%, ${dimScale[7]}%)`,
primary_600: `hsl(${BLUE_HUE}, 95%, ${tokens.dimScale[6]}%)`, primary_600: `hsl(${hues.primary}, 99%, ${dimScale[8]}%)`,
primary_700: `hsl(${BLUE_HUE}, 90%, ${tokens.dimScale[5]}%)`, primary_700: `hsl(${hues.primary}, 99%, ${dimScale[9]}%)`,
primary_800: `hsl(${BLUE_HUE}, 82%, ${tokens.dimScale[4]}%)`, primary_800: `hsl(${hues.primary}, 99%, ${dimScale[10]}%)`,
primary_900: `hsl(${BLUE_HUE}, 70%, ${tokens.dimScale[3]}%)`, primary_900: `hsl(${hues.primary}, 99%, ${dimScale[11]}%)`,
primary_950: `hsl(${BLUE_HUE}, 60%, ${tokens.dimScale[2]}%)`, primary_950: `hsl(${hues.primary}, 99%, ${dimScale[12]}%)`,
primary_975: `hsl(${BLUE_HUE}, 50%, ${tokens.dimScale[1]}%)`, primary_975: `hsl(${hues.primary}, 99%, ${dimScale[13]}%)`,
positive_25: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[13]}%)`, positive_25: `hsl(${hues.positive}, 50%, ${dimScale[1]}%)`,
positive_50: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[12]}%)`, positive_50: `hsl(${hues.positive}, 60%, ${dimScale[2]}%)`,
positive_100: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[11]}%)`, positive_100: `hsl(${hues.positive}, 70%, ${dimScale[3]}%)`,
positive_200: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[10]}%)`, positive_200: `hsl(${hues.positive}, 82%, ${dimScale[4]}%)`,
positive_300: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[9]}%)`, positive_300: `hsl(${hues.positive}, 82%, ${dimScale[5]}%)`,
positive_400: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[8]}%)`, positive_400: `hsl(${hues.positive}, 82%, ${dimScale[6]}%)`,
positive_500: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[7]}%)`, positive_500: `hsl(${hues.positive}, 82%, ${dimScale[7]}%)`,
positive_600: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[6]}%)`, positive_600: `hsl(${hues.positive}, 82%, ${dimScale[8]}%)`,
positive_700: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[5]}%)`, positive_700: `hsl(${hues.positive}, 82%, ${dimScale[9]}%)`,
positive_800: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[4]}%)`, positive_800: `hsl(${hues.positive}, 82%, ${dimScale[10]}%)`,
positive_900: `hsl(${GREEN_HUE}, 70%, ${tokens.dimScale[3]}%)`, positive_900: `hsl(${hues.positive}, 82%, ${dimScale[11]}%)`,
positive_950: `hsl(${GREEN_HUE}, 60%, ${tokens.dimScale[2]}%)`, positive_950: `hsl(${hues.positive}, 82%, ${dimScale[12]}%)`,
positive_975: `hsl(${GREEN_HUE}, 50%, ${tokens.dimScale[1]}%)`, positive_975: `hsl(${hues.positive}, 82%, ${dimScale[13]}%)`,
negative_25: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[13]}%)`, negative_25: `hsl(${hues.negative}, 70%, ${dimScale[1]}%)`,
negative_50: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[12]}%)`, negative_50: `hsl(${hues.negative}, 80%, ${dimScale[2]}%)`,
negative_100: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[11]}%)`, negative_100: `hsl(${hues.negative}, 84%, ${dimScale[3]}%)`,
negative_200: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[10]}%)`, negative_200: `hsl(${hues.negative}, 88%, ${dimScale[4]}%)`,
negative_300: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[9]}%)`, negative_300: `hsl(${hues.negative}, 91%, ${dimScale[5]}%)`,
negative_400: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[8]}%)`, negative_400: `hsl(${hues.negative}, 91%, ${dimScale[6]}%)`,
negative_500: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[7]}%)`, negative_500: `hsl(${hues.negative}, 91%, ${dimScale[7]}%)`,
negative_600: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[6]}%)`, negative_600: `hsl(${hues.negative}, 91%, ${dimScale[8]}%)`,
negative_700: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[5]}%)`, negative_700: `hsl(${hues.negative}, 91%, ${dimScale[9]}%)`,
negative_800: `hsl(${RED_HUE}, 88%, ${tokens.dimScale[4]}%)`, negative_800: `hsl(${hues.negative}, 91%, ${dimScale[10]}%)`,
negative_900: `hsl(${RED_HUE}, 84%, ${tokens.dimScale[3]}%)`, negative_900: `hsl(${hues.negative}, 91%, ${dimScale[11]}%)`,
negative_950: `hsl(${RED_HUE}, 80%, ${tokens.dimScale[2]}%)`, negative_950: `hsl(${hues.negative}, 91%, ${dimScale[12]}%)`,
negative_975: `hsl(${RED_HUE}, 70%, ${tokens.dimScale[1]}%)`, negative_975: `hsl(${hues.negative}, 91%, ${dimScale[13]}%)`,
} as const } as const
export const light = { const light: Theme = {
name: 'light' as ThemeName, name: 'light',
palette: lightPalette, palette: lightPalette,
atoms: { atoms: {
text: { text: {
@@ -277,8 +389,8 @@ export const light = {
}, },
} }
export const dark: Theme = { const dark: Theme = {
name: 'dark' as ThemeName, name: 'dark',
palette: darkPalette, palette: darkPalette,
atoms: { atoms: {
text: { text: {
@@ -350,24 +462,24 @@ export const dark: Theme = {
shadow_sm: { shadow_sm: {
...atoms.shadow_sm, ...atoms.shadow_sm,
shadowOpacity: 0.7, shadowOpacity: 0.7,
shadowColor: tokens.color.trueBlack, shadowColor: color.trueBlack,
}, },
shadow_md: { shadow_md: {
...atoms.shadow_md, ...atoms.shadow_md,
shadowOpacity: 0.7, shadowOpacity: 0.7,
shadowColor: tokens.color.trueBlack, shadowColor: color.trueBlack,
}, },
shadow_lg: { shadow_lg: {
...atoms.shadow_lg, ...atoms.shadow_lg,
shadowOpacity: 0.7, shadowOpacity: 0.7,
shadowColor: tokens.color.trueBlack, shadowColor: color.trueBlack,
}, },
}, },
} }
export const dim: Theme = { const dim: Theme = {
...dark, ...dark,
name: 'dim' as ThemeName, name: 'dim',
palette: dimPalette, palette: dimPalette,
atoms: { atoms: {
...dark.atoms, ...dark.atoms,
@@ -440,17 +552,27 @@ export const dim: Theme = {
shadow_sm: { shadow_sm: {
...atoms.shadow_sm, ...atoms.shadow_sm,
shadowOpacity: 0.7, shadowOpacity: 0.7,
shadowColor: `hsl(${BLUE_HUE}, 28%, 6%)`, shadowColor: `hsl(${hues.primary}, 28%, 6%)`,
}, },
shadow_md: { shadow_md: {
...atoms.shadow_md, ...atoms.shadow_md,
shadowOpacity: 0.7, shadowOpacity: 0.7,
shadowColor: `hsl(${BLUE_HUE}, 28%, 6%)`, shadowColor: `hsl(${hues.primary}, 28%, 6%)`,
}, },
shadow_lg: { shadow_lg: {
...atoms.shadow_lg, ...atoms.shadow_lg,
shadowOpacity: 0.7, shadowOpacity: 0.7,
shadowColor: `hsl(${BLUE_HUE}, 28%, 6%)`, shadowColor: `hsl(${hues.primary}, 28%, 6%)`,
}, },
}, },
} }
return {
lightPalette,
darkPalette,
dimPalette,
light,
dark,
dim,
}
}
-78
View File
@@ -1,77 +1,6 @@
import {
BLUE_HUE,
generateScale,
GREEN_HUE,
RED_HUE,
} from '#/alf/util/colorGeneration'
export const scale = generateScale(6, 100)
// dim shifted 6% lighter
export const dimScale = generateScale(12, 100)
export const color = { export const color = {
trueBlack: '#000000',
temp_purple: 'rgb(105 0 255)', temp_purple: 'rgb(105 0 255)',
temp_purple_dark: 'rgb(83 0 202)', temp_purple_dark: 'rgb(83 0 202)',
gray_0: `hsl(${BLUE_HUE}, 20%, ${scale[14]}%)`,
gray_25: `hsl(${BLUE_HUE}, 20%, ${scale[13]}%)`,
gray_50: `hsl(${BLUE_HUE}, 20%, ${scale[12]}%)`,
gray_100: `hsl(${BLUE_HUE}, 20%, ${scale[11]}%)`,
gray_200: `hsl(${BLUE_HUE}, 20%, ${scale[10]}%)`,
gray_300: `hsl(${BLUE_HUE}, 20%, ${scale[9]}%)`,
gray_400: `hsl(${BLUE_HUE}, 20%, ${scale[8]}%)`,
gray_500: `hsl(${BLUE_HUE}, 20%, ${scale[7]}%)`,
gray_600: `hsl(${BLUE_HUE}, 24%, ${scale[6]}%)`,
gray_700: `hsl(${BLUE_HUE}, 24%, ${scale[5]}%)`,
gray_800: `hsl(${BLUE_HUE}, 28%, ${scale[4]}%)`,
gray_900: `hsl(${BLUE_HUE}, 28%, ${scale[3]}%)`,
gray_950: `hsl(${BLUE_HUE}, 28%, ${scale[2]}%)`,
gray_975: `hsl(${BLUE_HUE}, 28%, ${scale[1]}%)`,
gray_1000: `hsl(${BLUE_HUE}, 28%, ${scale[0]}%)`,
blue_25: `hsl(${BLUE_HUE}, 99%, 97%)`,
blue_50: `hsl(${BLUE_HUE}, 99%, 95%)`,
blue_100: `hsl(${BLUE_HUE}, 99%, 90%)`,
blue_200: `hsl(${BLUE_HUE}, 99%, 80%)`,
blue_300: `hsl(${BLUE_HUE}, 99%, 70%)`,
blue_400: `hsl(${BLUE_HUE}, 99%, 60%)`,
blue_500: `hsl(${BLUE_HUE}, 99%, 53%)`,
blue_600: `hsl(${BLUE_HUE}, 99%, 42%)`,
blue_700: `hsl(${BLUE_HUE}, 99%, 34%)`,
blue_800: `hsl(${BLUE_HUE}, 99%, 26%)`,
blue_900: `hsl(${BLUE_HUE}, 99%, 18%)`,
blue_950: `hsl(${BLUE_HUE}, 99%, 10%)`,
blue_975: `hsl(${BLUE_HUE}, 99%, 7%)`,
green_25: `hsl(${GREEN_HUE}, 82%, 97%)`,
green_50: `hsl(${GREEN_HUE}, 82%, 95%)`,
green_100: `hsl(${GREEN_HUE}, 82%, 90%)`,
green_200: `hsl(${GREEN_HUE}, 82%, 80%)`,
green_300: `hsl(${GREEN_HUE}, 82%, 70%)`,
green_400: `hsl(${GREEN_HUE}, 82%, 60%)`,
green_500: `hsl(${GREEN_HUE}, 82%, 50%)`,
green_600: `hsl(${GREEN_HUE}, 82%, 42%)`,
green_700: `hsl(${GREEN_HUE}, 82%, 34%)`,
green_800: `hsl(${GREEN_HUE}, 82%, 26%)`,
green_900: `hsl(${GREEN_HUE}, 82%, 18%)`,
green_950: `hsl(${GREEN_HUE}, 82%, 10%)`,
green_975: `hsl(${GREEN_HUE}, 82%, 7%)`,
red_25: `hsl(${RED_HUE}, 91%, 97%)`,
red_50: `hsl(${RED_HUE}, 91%, 95%)`,
red_100: `hsl(${RED_HUE}, 91%, 90%)`,
red_200: `hsl(${RED_HUE}, 91%, 80%)`,
red_300: `hsl(${RED_HUE}, 91%, 70%)`,
red_400: `hsl(${RED_HUE}, 91%, 60%)`,
red_500: `hsl(${RED_HUE}, 91%, 50%)`,
red_600: `hsl(${RED_HUE}, 91%, 42%)`,
red_700: `hsl(${RED_HUE}, 91%, 34%)`,
red_800: `hsl(${RED_HUE}, 91%, 26%)`,
red_900: `hsl(${RED_HUE}, 91%, 18%)`,
red_950: `hsl(${RED_HUE}, 91%, 10%)`,
red_975: `hsl(${RED_HUE}, 91%, 7%)`,
} as const } as const
export const space = { export const space = {
@@ -178,10 +107,3 @@ export const gradients = {
hover_value: '#755B62', hover_value: '#755B62',
}, },
} as const } 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
+154 -18
View File
@@ -1,21 +1,4 @@
import {StyleProp, ViewStyle, TextStyle} from 'react-native' import {StyleProp, TextStyle, ViewStyle} from 'react-native'
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]>
}
export type TextStyleProp = { export type TextStyleProp = {
style?: StyleProp<TextStyle> style?: StyleProp<TextStyle>
@@ -24,3 +7,156 @@ export type TextStyleProp = {
export type ViewStyleProp = { export type ViewStyleProp = {
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
} }
export type ThemeName = 'light' | 'dim' | 'dark'
export type Palette = {
white: string
black: string
contrast_25: string
contrast_50: string
contrast_100: string
contrast_200: string
contrast_300: string
contrast_400: string
contrast_500: string
contrast_600: string
contrast_700: string
contrast_800: string
contrast_900: string
contrast_950: string
contrast_975: string
primary_25: string
primary_50: string
primary_100: string
primary_200: string
primary_300: string
primary_400: string
primary_500: string
primary_600: string
primary_700: string
primary_800: string
primary_900: string
primary_950: string
primary_975: string
positive_25: string
positive_50: string
positive_100: string
positive_200: string
positive_300: string
positive_400: string
positive_500: string
positive_600: string
positive_700: string
positive_800: string
positive_900: string
positive_950: string
positive_975: string
negative_25: string
negative_50: string
negative_100: string
negative_200: string
negative_300: string
negative_400: string
negative_500: string
negative_600: string
negative_700: string
negative_800: string
negative_900: string
negative_950: string
negative_975: string
}
export type ThemedAtoms = {
text: {
color: string
}
text_contrast_low: {
color: string
}
text_contrast_medium: {
color: string
}
text_contrast_high: {
color: string
}
text_inverted: {
color: string
}
bg: {
backgroundColor: string
}
bg_contrast_25: {
backgroundColor: string
}
bg_contrast_50: {
backgroundColor: string
}
bg_contrast_100: {
backgroundColor: string
}
bg_contrast_200: {
backgroundColor: string
}
bg_contrast_300: {
backgroundColor: string
}
bg_contrast_400: {
backgroundColor: string
}
bg_contrast_500: {
backgroundColor: string
}
bg_contrast_600: {
backgroundColor: string
}
bg_contrast_700: {
backgroundColor: string
}
bg_contrast_800: {
backgroundColor: string
}
bg_contrast_900: {
backgroundColor: string
}
bg_contrast_950: {
backgroundColor: string
}
bg_contrast_975: {
backgroundColor: string
}
border_contrast_low: {
borderColor: string
}
border_contrast_medium: {
borderColor: string
}
border_contrast_high: {
borderColor: string
}
shadow_sm: {
shadowRadius: number
shadowOpacity: number
elevation: number
shadowColor: string
}
shadow_md: {
shadowRadius: number
shadowOpacity: number
elevation: number
shadowColor: string
}
shadow_lg: {
shadowRadius: number
shadowOpacity: number
elevation: number
shadowColor: string
}
}
export type Theme = {
name: ThemeName
palette: Palette
atoms: ThemedAtoms
}
+4
View File
@@ -15,3 +15,7 @@ export function generateScale(start: number, end: number) {
return start + range * stop return start + range * stop
}) })
} }
export const defaultScale = generateScale(6, 100)
// dim shifted 6% lighter
export const dimScale = generateScale(12, 100)
+1 -1
View File
@@ -1,4 +1,4 @@
import {ThemeName} from '#/alf/themes' import {ThemeName} from '#/alf/types'
export function select<T>(name: ThemeName, options: Record<ThemeName, T>) { export function select<T>(name: ThemeName, options: Record<ThemeName, T>) {
switch (name) { switch (name) {
+2 -1
View File
@@ -4,7 +4,8 @@ import * as SystemUI from 'expo-system-ui'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {useThemePrefs} from 'state/shell' import {useThemePrefs} from 'state/shell'
import {dark, dim, light, ThemeName} from '#/alf/themes' import {dark, dim, light} from '#/alf/themes'
import {ThemeName} from '#/alf/types'
export function useColorModeTheme(): ThemeName { export function useColorModeTheme(): ThemeName {
const theme = useThemeName() const theme = useThemeName()
+104 -27
View File
@@ -13,7 +13,7 @@ import {
} from 'react-native' } from 'react-native'
import {LinearGradient} from 'expo-linear-gradient' import {LinearGradient} from 'expo-linear-gradient'
import {android, atoms as a, flatten, tokens, useTheme} from '#/alf' import {android, atoms as a, flatten, select, tokens, useTheme} from '#/alf'
import {Props as SVGIconProps} from '#/components/icons/common' import {Props as SVGIconProps} from '#/components/icons/common'
import {normalizeTextStyles} from '#/components/Typography' import {normalizeTextStyles} from '#/components/Typography'
@@ -21,6 +21,7 @@ export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
export type ButtonColor = export type ButtonColor =
| 'primary' | 'primary'
| 'secondary' | 'secondary'
| 'secondary_inverted'
| 'negative' | 'negative'
| 'gradient_sky' | 'gradient_sky'
| 'gradient_midnight' | 'gradient_midnight'
@@ -151,7 +152,6 @@ export const Button = React.forwardRef<View, ButtonProps>(
const {baseStyles, hoverStyles} = React.useMemo(() => { const {baseStyles, hoverStyles} = React.useMemo(() => {
const baseStyles: ViewStyle[] = [] const baseStyles: ViewStyle[] = []
const hoverStyles: ViewStyle[] = [] const hoverStyles: ViewStyle[] = []
const light = t.name === 'light'
if (color === 'primary') { if (color === 'primary') {
if (variant === 'solid') { if (variant === 'solid') {
@@ -164,7 +164,11 @@ export const Button = React.forwardRef<View, ButtonProps>(
}) })
} else { } else {
baseStyles.push({ baseStyles.push({
backgroundColor: t.palette.primary_700, backgroundColor: select(t.name, {
light: t.palette.primary_700,
dim: t.palette.primary_300,
dark: t.palette.primary_300,
}),
}) })
} }
} else if (variant === 'outline') { } else if (variant === 'outline') {
@@ -177,24 +181,18 @@ export const Button = React.forwardRef<View, ButtonProps>(
borderColor: t.palette.primary_500, borderColor: t.palette.primary_500,
}) })
hoverStyles.push(a.border, { hoverStyles.push(a.border, {
backgroundColor: light backgroundColor: t.palette.primary_50,
? t.palette.primary_50
: t.palette.primary_950,
}) })
} else { } else {
baseStyles.push(a.border, { baseStyles.push(a.border, {
borderColor: light borderColor: t.palette.primary_200,
? t.palette.primary_200
: t.palette.primary_900,
}) })
} }
} else if (variant === 'ghost') { } else if (variant === 'ghost') {
if (!disabled) { if (!disabled) {
baseStyles.push(t.atoms.bg) baseStyles.push(t.atoms.bg)
hoverStyles.push({ hoverStyles.push({
backgroundColor: light backgroundColor: t.palette.primary_100,
? t.palette.primary_100
: t.palette.primary_900,
}) })
} }
} }
@@ -202,14 +200,63 @@ export const Button = React.forwardRef<View, ButtonProps>(
if (variant === 'solid') { if (variant === 'solid') {
if (!disabled) { if (!disabled) {
baseStyles.push({ baseStyles.push({
backgroundColor: t.palette.contrast_25, backgroundColor: select(t.name, {
light: t.palette.contrast_25,
dim: t.palette.contrast_100,
dark: t.palette.contrast_100,
}),
}) })
hoverStyles.push({ hoverStyles.push({
backgroundColor: t.palette.contrast_50, backgroundColor: select(t.name, {
light: t.palette.contrast_50,
dim: t.palette.contrast_200,
dark: t.palette.contrast_200,
}),
}) })
} else { } else {
baseStyles.push({ baseStyles.push({
backgroundColor: t.palette.contrast_100, backgroundColor: select(t.name, {
light: t.palette.contrast_100,
dim: t.palette.contrast_25,
dark: t.palette.contrast_25,
}),
})
}
} else if (variant === 'outline') {
baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(a.border, {
borderColor: t.palette.contrast_300,
})
hoverStyles.push(t.atoms.bg_contrast_50)
} else {
baseStyles.push(a.border, {
borderColor: t.palette.contrast_200,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: t.palette.contrast_25,
})
}
}
} else if (color === 'secondary_inverted') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.contrast_900,
})
hoverStyles.push({
backgroundColor: t.palette.contrast_950,
})
} else {
baseStyles.push({
backgroundColor: t.palette.contrast_600,
}) })
} }
} else if (variant === 'outline') { } else if (variant === 'outline') {
@@ -246,7 +293,11 @@ export const Button = React.forwardRef<View, ButtonProps>(
}) })
} else { } else {
baseStyles.push({ baseStyles.push({
backgroundColor: t.palette.negative_700, backgroundColor: select(t.name, {
light: t.palette.negative_700,
dim: t.palette.negative_300,
dark: t.palette.negative_300,
}),
}) })
} }
} else if (variant === 'outline') { } else if (variant === 'outline') {
@@ -259,24 +310,18 @@ export const Button = React.forwardRef<View, ButtonProps>(
borderColor: t.palette.negative_500, borderColor: t.palette.negative_500,
}) })
hoverStyles.push(a.border, { hoverStyles.push(a.border, {
backgroundColor: light backgroundColor: t.palette.negative_50,
? t.palette.negative_50
: t.palette.negative_975,
}) })
} else { } else {
baseStyles.push(a.border, { baseStyles.push(a.border, {
borderColor: light borderColor: t.palette.negative_200,
? t.palette.negative_200
: t.palette.negative_900,
}) })
} }
} else if (variant === 'ghost') { } else if (variant === 'ghost') {
if (!disabled) { if (!disabled) {
baseStyles.push(t.atoms.bg) baseStyles.push(t.atoms.bg)
hoverStyles.push({ hoverStyles.push({
backgroundColor: light backgroundColor: t.palette.negative_100,
? t.palette.negative_100
: t.palette.negative_975,
}) })
} }
} }
@@ -344,6 +389,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
const gradient = { const gradient = {
primary: tokens.gradients.sky, primary: tokens.gradients.sky,
secondary: tokens.gradients.sky, secondary: tokens.gradients.sky,
secondary_inverted: tokens.gradients.sky,
negative: tokens.gradients.sky, negative: tokens.gradients.sky,
gradient_sky: tokens.gradients.sky, gradient_sky: tokens.gradients.sky,
gradient_midnight: tokens.gradients.midnight, gradient_midnight: tokens.gradients.midnight,
@@ -443,7 +489,6 @@ export function useSharedButtonTextStyles() {
const {color, variant, disabled, size} = useButtonContext() const {color, variant, disabled, size} = useButtonContext()
return React.useMemo(() => { return React.useMemo(() => {
const baseStyles: TextStyle[] = [] const baseStyles: TextStyle[] = []
const light = t.name === 'light'
if (color === 'primary') { if (color === 'primary') {
if (variant === 'solid') { if (variant === 'solid') {
@@ -455,7 +500,7 @@ export function useSharedButtonTextStyles() {
} else if (variant === 'outline') { } else if (variant === 'outline') {
if (!disabled) { if (!disabled) {
baseStyles.push({ baseStyles.push({
color: light ? t.palette.primary_600 : t.palette.primary_500, color: t.palette.primary_600,
}) })
} else { } else {
baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
@@ -499,6 +544,38 @@ export function useSharedButtonTextStyles() {
}) })
} }
} }
} else if (color === 'secondary_inverted') {
if (variant === 'solid' || variant === 'gradient') {
if (!disabled) {
baseStyles.push({
color: t.palette.contrast_100,
})
} else {
baseStyles.push({
color: t.palette.contrast_400,
})
}
} else if (variant === 'outline') {
if (!disabled) {
baseStyles.push({
color: t.palette.contrast_600,
})
} else {
baseStyles.push({
color: t.palette.contrast_300,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push({
color: t.palette.contrast_600,
})
} else {
baseStyles.push({
color: t.palette.contrast_300,
})
}
}
} else if (color === 'negative') { } else if (color === 'negative') {
if (variant === 'solid' || variant === 'gradient') { if (variant === 'solid' || variant === 'gradient') {
if (!disabled) { if (!disabled) {
+57 -76
View File
@@ -18,7 +18,7 @@ import {
useRemoveFeedMutation, useRemoveFeedMutation,
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
import {precacheFeedFromGeneratorView, precacheList} from 'state/queries/feed' import {precacheFeedFromGeneratorView} from 'state/queries/feed'
import {useSession} from 'state/session' import {useSession} from 'state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import * as Toast from 'view/com/util/Toast' import * as Toast from 'view/com/util/Toast'
@@ -30,48 +30,34 @@ import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Link as InternalLink, LinkProps} from '#/components/Link' import {Link as InternalLink, LinkProps} from '#/components/Link'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText' import {RichText, RichTextProps} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
type Props = type Props = {
| {
type: 'feed'
view: AppBskyFeedDefs.GeneratorView view: AppBskyFeedDefs.GeneratorView
} }
| {
type: 'list'
view: AppBskyGraphDefs.ListView
}
export function Default(props: Props) { export function Default(props: Props) {
const {type, view} = props const {view} = props
const displayName = type === 'feed' ? view.displayName : view.name
const purpose = type === 'list' ? view.purpose : undefined
return ( return (
<Link label={displayName} {...props}> <Link label={view.displayName} {...props}>
<Outer> <Outer>
<Header> <Header>
<Avatar src={view.avatar} /> <Avatar src={view.avatar} />
<TitleAndByline <TitleAndByline title={view.displayName} creator={view.creator} />
title={displayName} <SaveButton view={view} pin />
creator={view.creator}
type={type}
purpose={purpose}
/>
<Action uri={view.uri} pin type={type} purpose={purpose} />
</Header> </Header>
<Description description={view.description} /> <Description description={view.description} />
{type === 'feed' && <Likes count={view.likeCount || 0} />} <Likes count={view.likeCount || 0} />
</Outer> </Outer>
</Link> </Link>
) )
} }
export function Link({ export function Link({
type,
view, view,
label,
children, children,
...props
}: Props & Omit<LinkProps, 'to'>) { }: Props & Omit<LinkProps, 'to'>) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -79,32 +65,23 @@ export function Link({
return createProfileFeedHref({feed: view}) return createProfileFeedHref({feed: view})
}, [view]) }, [view])
return ( React.useEffect(() => {
<InternalLink
to={href}
label={label}
onPress={() => {
if (type === 'feed') {
precacheFeedFromGeneratorView(queryClient, view) precacheFeedFromGeneratorView(queryClient, view)
} else { }, [view, queryClient])
precacheList(queryClient, view)
} return (
}}> <InternalLink to={href} style={[a.flex_col]} {...props}>
{children} {children}
</InternalLink> </InternalLink>
) )
} }
export function Outer({children}: {children: React.ReactNode}) { export function Outer({children}: {children: React.ReactNode}) {
return <View style={[a.flex_1, a.gap_md]}>{children}</View> return <View style={[a.w_full, a.gap_md]}>{children}</View>
} }
export function Header({children}: {children: React.ReactNode}) { export function Header({children}: {children: React.ReactNode}) {
return ( return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View>
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_md]}>
{children}
</View>
)
} }
export type AvatarProps = {src: string | undefined; size?: number} export type AvatarProps = {src: string | undefined; size?: number}
@@ -132,13 +109,9 @@ export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
export function TitleAndByline({ export function TitleAndByline({
title, title,
creator, creator,
type,
purpose,
}: { }: {
title: string title: string
creator?: AppBskyActorDefs.ProfileViewBasic creator?: AppBskyActorDefs.ProfileViewBasic
type: 'feed' | 'list'
purpose?: AppBskyGraphDefs.ListView['purpose']
}) { }) {
const t = useTheme() const t = useTheme()
@@ -151,15 +124,7 @@ export function TitleAndByline({
<Text <Text
style={[a.leading_snug, t.atoms.text_contrast_medium]} style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}> numberOfLines={1}>
{type === 'list' && purpose === 'app.bsky.graph.defs#curatelist' ? (
<Trans>List by {sanitizeHandle(creator.handle, '@')}</Trans>
) : type === 'list' && purpose === 'app.bsky.graph.defs#modlist' ? (
<Trans>
Moderation list by {sanitizeHandle(creator.handle, '@')}
</Trans>
) : (
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans> <Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
)}
</Text> </Text>
)} )}
</View> </View>
@@ -198,7 +163,10 @@ export function TitleAndBylinePlaceholder({creator}: {creator?: boolean}) {
) )
} }
export function Description({description}: {description?: string}) { export function Description({
description,
...rest
}: {description?: string} & Partial<RichTextProps>) {
const rt = React.useMemo(() => { const rt = React.useMemo(() => {
if (!description) return if (!description) return
const rt = new RichTextApi({text: description || ''}) const rt = new RichTextApi({text: description || ''})
@@ -206,7 +174,29 @@ export function Description({description}: {description?: string}) {
return rt return rt
}, [description]) }, [description])
if (!rt) return null if (!rt) return null
return <RichText value={rt} style={[a.leading_snug]} disableLinks /> return <RichText value={rt} style={[a.leading_snug]} disableLinks {...rest} />
}
export function DescriptionPlaceholder() {
const t = useTheme()
return (
<View style={[a.gap_xs]}>
<View
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
/>
<View
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
/>
<View
style={[
a.rounded_xs,
a.w_full,
t.atoms.bg_contrast_50,
{height: 12, width: 100},
]}
/>
</View>
)
} }
export function Likes({count}: {count: number}) { export function Likes({count}: {count: number}) {
@@ -221,34 +211,24 @@ export function Likes({count}: {count: number}) {
) )
} }
export function Action({ export function SaveButton({
uri, view,
pin, pin,
type,
purpose,
}: { }: {
uri: string view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
pin?: boolean pin?: boolean
type: 'feed' | 'list'
purpose?: AppBskyGraphDefs.ListView['purpose']
}) { }) {
const {hasSession} = useSession() const {hasSession} = useSession()
if ( if (!hasSession) return null
!hasSession || return <SaveButtonInner view={view} pin={pin} />
(type === 'list' && purpose !== 'app.bsky.graph.defs#curatelist')
)
return null
return <ActionInner uri={uri} pin={pin} type={type} />
} }
function ActionInner({ function SaveButtonInner({
uri, view,
pin, pin,
type,
}: { }: {
uri: string view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
pin?: boolean pin?: boolean
type: 'feed' | 'list'
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const {data: preferences} = usePreferencesQuery() const {data: preferences} = usePreferencesQuery()
@@ -256,6 +236,10 @@ function ActionInner({
useAddSavedFeedsMutation() useAddSavedFeedsMutation()
const {isPending: isRemovePending, mutateAsync: removeFeed} = const {isPending: isRemovePending, mutateAsync: removeFeed} =
useRemoveFeedMutation() useRemoveFeedMutation()
const uri = view.uri
const type = view.uri.includes('app.bsky.feed.generator') ? 'feed' : 'list'
const savedFeedConfig = React.useMemo(() => { const savedFeedConfig = React.useMemo(() => {
return preferences?.savedFeeds?.find(feed => feed.value === uri) return preferences?.savedFeeds?.find(feed => feed.value === uri)
}, [preferences?.savedFeeds, uri]) }, [preferences?.savedFeeds, uri])
@@ -332,12 +316,9 @@ function ActionInner({
export function createProfileFeedHref({ export function createProfileFeedHref({
feed, feed,
}: { }: {
feed: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView feed: AppBskyFeedDefs.GeneratorView
}) { }) {
const urip = new AtUri(feed.uri) const urip = new AtUri(feed.uri)
const type = urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'list'
const handleOrDid = feed.creator.handle || feed.creator.did const handleOrDid = feed.creator.handle || feed.creator.did
return `/profile/${handleOrDid}/${type === 'feed' ? 'feed' : 'lists'}/${ return `/profile/${handleOrDid}/feed/${urip.rkey}`
urip.rkey
}`
} }
+460
View File
@@ -0,0 +1,460 @@
import React from 'react'
import {View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {useProfilesQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useProgressGuide} from '#/state/shell/progress-guide'
import * as userActionHistory from '#/state/userActionHistory'
import {SeenPost} from '#/state/userActionHistory'
import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf'
import {Button} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard'
import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {PersonPlus_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import {ProgressGuideList} from './ProgressGuide/List'
const MOBILE_CARD_WIDTH = 300
function CardOuter({
children,
style,
}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
return (
<View
style={[
a.w_full,
a.p_lg,
a.rounded_md,
a.border,
t.atoms.bg,
t.atoms.border_contrast_low,
!gtMobile && {
width: MOBILE_CARD_WIDTH,
},
style,
]}>
{children}
</View>
)
}
export function SuggestedFollowPlaceholder() {
const t = useTheme()
return (
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
<ProfileCard.Header>
<ProfileCard.AvatarPlaceholder />
</ProfileCard.Header>
<View style={[a.py_xs]}>
<ProfileCard.NameAndHandlePlaceholder />
</View>
<ProfileCard.DescriptionPlaceholder />
</CardOuter>
)
}
export function SuggestedFeedsCardPlaceholder() {
const t = useTheme()
return (
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
<FeedCard.Header>
<FeedCard.AvatarPlaceholder />
<FeedCard.TitleAndBylinePlaceholder creator />
</FeedCard.Header>
<FeedCard.DescriptionPlaceholder />
</CardOuter>
)
}
function getRank(seenPost: SeenPost): string {
let tier: string
if (seenPost.feedContext === 'popfriends') {
tier = 'a'
} else if (seenPost.feedContext?.startsWith('cluster')) {
tier = 'b'
} else if (seenPost.feedContext?.startsWith('ntpc')) {
tier = 'c'
} else if (seenPost.feedContext?.startsWith('t-')) {
tier = 'd'
} else if (seenPost.feedContext === 'nettop') {
tier = 'e'
} else {
tier = 'f'
}
let score = Math.round(
Math.log(
1 + seenPost.likeCount + seenPost.repostCount + seenPost.replyCount,
),
)
if (seenPost.isFollowedBy || Math.random() > 0.9) {
score *= 2
}
const rank = 100 - score
return `${tier}-${rank}`
}
function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
const rankA = getRank(postA)
const rankB = getRank(postB)
// Yes, we're comparing strings here.
// The "larger" string means a worse rank.
if (rankA > rankB) {
return 1
} else if (rankA < rankB) {
return -1
} else {
return 0
}
}
function useExperimentalSuggestedUsersQuery() {
const {currentAccount} = useSession()
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
const dids = React.useMemo(() => {
const {likes, follows, seen} = userActionSnapshot
const likeDids = likes
.map(l => new AtUri(l))
.map(uri => uri.host)
.filter(did => !follows.includes(did))
const seenDids = seen
.sort(sortSeenPosts)
.map(l => new AtUri(l.uri))
.map(uri => uri.host)
return [...new Set([...likeDids, ...seenDids])].filter(
did => did !== currentAccount?.did,
)
}, [userActionSnapshot, currentAccount])
const {data, isLoading, error} = useProfilesQuery({
handles: dids.slice(0, 16),
})
const profiles = data
? data.profiles.filter(profile => {
return !profile.viewer?.following
})
: []
return {
isLoading,
error,
profiles: profiles.slice(0, 6),
}
}
export function SuggestedFollows() {
const t = useTheme()
const {_} = useLingui()
const {
isLoading: isSuggestionsLoading,
profiles,
error,
} = useExperimentalSuggestedUsersQuery()
const moderationOpts = useModerationOpts()
const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints()
const isLoading = isSuggestionsLoading || !moderationOpts
const maxLength = gtMobile ? 4 : 6
const content = isLoading ? (
Array(maxLength)
.fill(0)
.map((_, i) => (
<View
key={i}
style={[gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}])]}>
<SuggestedFollowPlaceholder />
</View>
))
) : error || !profiles.length ? null : (
<>
{profiles.slice(0, maxLength).map(profile => (
<ProfileCard.Link
key={profile.did}
did={profile.handle}
onPress={() => {
logEvent('feed:interstitial:profileCard:press', {})
}}
style={[
a.flex_1,
gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}]),
]}>
{({hovered, pressed}) => (
<CardOuter
style={[
a.flex_1,
(hovered || pressed) && t.atoms.border_contrast_high,
]}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.FollowButton
profile={profile}
moderationOpts={moderationOpts}
logContext="FeedInterstitial"
color="secondary_inverted"
shape="round"
/>
</ProfileCard.Header>
<ProfileCard.Description profile={profile} />
</ProfileCard.Outer>
</CardOuter>
)}
</ProfileCard.Link>
))}
</>
)
if (error || (!isLoading && profiles.length < 4)) {
logger.debug(`Not enough profiles to show suggested follows`)
return null
}
return (
<View
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
<View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
<Text
style={[
a.flex_1,
a.text_lg,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Suggested for you</Trans>
</Text>
<Person fill={t.atoms.text_contrast_low.color} />
</View>
{gtMobile ? (
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
{content}
</View>
<View
style={[
a.flex_row,
a.justify_end,
a.align_center,
a.pt_xs,
a.gap_md,
]}>
<InlineLinkText to="/search" style={[t.atoms.text_contrast_medium]}>
<Trans>Browse more suggestions</Trans>
</InlineLinkText>
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
</View>
</View>
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
{content}
<Button
label={_(msg`Browse more accounts on the Explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}>
<CardOuter style={[a.flex_1, {borderWidth: 0}]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
<Trans>Browse more suggestions on the Explore page</Trans>
</Text>
<Arrow size="xl" />
</View>
</View>
</CardOuter>
</Button>
</View>
</ScrollView>
)}
</View>
)
}
export function SuggestedFeeds() {
const numFeedsToDisplay = 3
const t = useTheme()
const {_} = useLingui()
const {data, isLoading, error} = useGetPopularFeedsQuery({
limit: numFeedsToDisplay,
})
const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints()
const feeds = React.useMemo(() => {
const items: AppBskyFeedDefs.GeneratorView[] = []
if (!data) return items
for (const page of data.pages) {
for (const feed of page.feeds) {
items.push(feed)
}
}
return items
}, [data])
const content = isLoading ? (
Array(numFeedsToDisplay)
.fill(0)
.map((_, i) => <SuggestedFeedsCardPlaceholder key={i} />)
) : error || !feeds ? null : (
<>
{feeds.slice(0, numFeedsToDisplay).map(feed => (
<FeedCard.Link
key={feed.uri}
view={feed}
onPress={() => {
logEvent('feed:interstitial:feedCard:press', {})
}}>
{({hovered, pressed}) => (
<CardOuter
style={[
a.flex_1,
(hovered || pressed) && t.atoms.border_contrast_high,
]}>
<FeedCard.Outer>
<FeedCard.Header>
<FeedCard.Avatar src={feed.avatar} />
<FeedCard.TitleAndByline
title={feed.displayName}
creator={feed.creator}
/>
</FeedCard.Header>
<FeedCard.Description
description={feed.description}
numberOfLines={3}
/>
</FeedCard.Outer>
</CardOuter>
)}
</FeedCard.Link>
))}
</>
)
return error ? null : (
<View
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
<View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
<Text
style={[
a.flex_1,
a.text_lg,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Some other feeds you might like</Trans>
</Text>
<Hashtag fill={t.atoms.text_contrast_low.color} />
</View>
{gtMobile ? (
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
{content}
<View
style={[
a.flex_row,
a.justify_end,
a.align_center,
a.pt_xs,
a.gap_md,
]}>
<InlineLinkText to="/search" style={[t.atoms.text_contrast_medium]}>
<Trans>Browse more suggestions</Trans>
</InlineLinkText>
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
</View>
</View>
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
{content}
<Button
label={_(msg`Browse more feeds on the Explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}
style={[a.flex_col]}>
<CardOuter style={[a.flex_1]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
<Trans>Browse more suggestions on the Explore page</Trans>
</Text>
<Arrow size="xl" />
</View>
</View>
</CardOuter>
</Button>
</View>
</ScrollView>
)}
</View>
)
}
export function ProgressGuide() {
const t = useTheme()
const {isDesktop} = useWebMediaQueries()
const guide = useProgressGuide('like-10-and-follow-7')
if (isDesktop) {
return null
}
return guide ? (
<View
style={[
a.border_t,
t.atoms.border_contrast_low,
a.px_lg,
a.py_lg,
a.pb_lg,
]}>
<ProgressGuideList />
</View>
) : null
}
+5 -6
View File
@@ -2,14 +2,14 @@ import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import { import {
useTheme,
atoms as a, atoms as a,
ViewStyleProp,
TextStyleProp,
flatten, flatten,
TextStyleProp,
useTheme,
ViewStyleProp,
} from '#/alf' } from '#/alf'
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
import {Props} from '#/components/icons/common' import {Props} from '#/components/icons/common'
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
export function IconCircle({ export function IconCircle({
icon: Icon, icon: Icon,
@@ -32,8 +32,7 @@ export function IconCircle({
{ {
width: size === 'lg' ? 52 : 64, width: size === 'lg' ? 52 : 64,
height: size === 'lg' ? 52 : 64, height: size === 'lg' ? 52 : 64,
backgroundColor: backgroundColor: t.palette.primary_50,
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
}, },
flatten(style), flatten(style),
]}> ]}>
+14 -6
View File
@@ -12,6 +12,7 @@ import {Link, LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
const AVI_SIZE = 30 const AVI_SIZE = 30
const AVI_SIZE_SMALL = 20
const AVI_BORDER = 1 const AVI_BORDER = 1
/** /**
@@ -30,10 +31,12 @@ export function KnownFollowers({
profile, profile,
moderationOpts, moderationOpts,
onLinkPress, onLinkPress,
minimal,
}: { }: {
profile: AppBskyActorDefs.ProfileViewDetailed profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
onLinkPress?: LinkProps['onPress'] onLinkPress?: LinkProps['onPress']
minimal?: boolean
}) { }) {
const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>( const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
new Map(), new Map(),
@@ -59,6 +62,7 @@ export function KnownFollowers({
cachedKnownFollowers={cachedKnownFollowers} cachedKnownFollowers={cachedKnownFollowers}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
onLinkPress={onLinkPress} onLinkPress={onLinkPress}
minimal={minimal}
/> />
) )
} }
@@ -71,11 +75,13 @@ function KnownFollowersInner({
moderationOpts, moderationOpts,
cachedKnownFollowers, cachedKnownFollowers,
onLinkPress, onLinkPress,
minimal,
}: { }: {
profile: AppBskyActorDefs.ProfileViewDetailed profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
cachedKnownFollowers: AppBskyActorDefs.KnownFollowers cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
onLinkPress?: LinkProps['onPress'] onLinkPress?: LinkProps['onPress']
minimal?: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
@@ -110,6 +116,8 @@ function KnownFollowersInner({
*/ */
if (slice.length === 0) return null if (slice.length === 0) return null
const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE
return ( return (
<Link <Link
label={_( label={_(
@@ -120,7 +128,7 @@ function KnownFollowersInner({
style={[ style={[
a.flex_1, a.flex_1,
a.flex_row, a.flex_row,
a.gap_md, minimal ? a.gap_sm : a.gap_md,
a.align_center, a.align_center,
{marginLeft: -AVI_BORDER}, {marginLeft: -AVI_BORDER},
]}> ]}>
@@ -129,8 +137,8 @@ function KnownFollowersInner({
<View <View
style={[ style={[
{ {
height: AVI_SIZE, height: SIZE,
width: AVI_SIZE + (slice.length - 1) * a.gap_md.gap, width: SIZE + (slice.length - 1) * a.gap_md.gap,
}, },
pressed && { pressed && {
opacity: 0.5, opacity: 0.5,
@@ -145,14 +153,14 @@ function KnownFollowersInner({
{ {
borderWidth: AVI_BORDER, borderWidth: AVI_BORDER,
borderColor: t.atoms.bg.backgroundColor, borderColor: t.atoms.bg.backgroundColor,
width: AVI_SIZE + AVI_BORDER * 2, width: SIZE + AVI_BORDER * 2,
height: AVI_SIZE + AVI_BORDER * 2, height: SIZE + AVI_BORDER * 2,
left: i * a.gap_md.gap, left: i * a.gap_md.gap,
zIndex: AVI_BORDER - i, zIndex: AVI_BORDER - i,
}, },
]}> ]}>
<UserAvatar <UserAvatar
size={AVI_SIZE} size={SIZE}
avatar={prof.avatar} avatar={prof.avatar}
moderation={moderation.ui('avatar')} moderation={moderation.ui('avatar')}
/> />
+48 -1
View File
@@ -1,5 +1,10 @@
import React from 'react' import React from 'react'
import {GestureResponderEvent} from 'react-native' import {
GestureResponderEvent,
Pressable,
StyleProp,
ViewStyle,
} from 'react-native'
import {sanitizeUrl} from '@braintree/sanitize-url' import {sanitizeUrl} from '@braintree/sanitize-url'
import {StackActions, useLinkProps} from '@react-navigation/native' import {StackActions, useLinkProps} from '@react-navigation/native'
@@ -323,3 +328,45 @@ export function InlineLinkText({
</Text> </Text>
) )
} }
/**
* A Pressable that uses useLink to handle navigation. It is unstyled, so can be used in cases where the Button styles
* in Link are not desired.
* @param displayText
* @param style
* @param children
* @param rest
* @constructor
*/
export function BaseLink({
displayText,
onPress: onPressOuter,
style,
children,
...rest
}: {
style?: StyleProp<ViewStyle>
children: React.ReactNode
to: string
action: 'push' | 'replace' | 'navigate'
onPress?: () => false | void
shareOnLongPress?: boolean
label: string
displayText?: string
}) {
const {onPress, ...btnProps} = useLink({
displayText: displayText ?? rest.to,
...rest,
})
return (
<Pressable
style={style}
onPress={e => {
onPressOuter?.()
onPress(e)
}}
{...btnProps}>
{children}
</Pressable>
)
}
+129
View File
@@ -0,0 +1,129 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useQueryClient} from '@tanstack/react-query'
import {sanitizeHandle} from 'lib/strings/handles'
import {precacheList} from 'state/queries/feed'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {
Avatar,
Description,
Header,
Outer,
SaveButton,
} from '#/components/FeedCard'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography'
/*
* This component is based on `FeedCard` and is tightly coupled with that
* component. Please refer to `FeedCard` for more context.
*/
export {
Avatar,
AvatarPlaceholder,
Description,
Header,
Outer,
SaveButton,
TitleAndBylinePlaceholder,
} from '#/components/FeedCard'
const CURATELIST = 'app.bsky.graph.defs#curatelist'
const MODLIST = 'app.bsky.graph.defs#modlist'
type Props = {
view: AppBskyGraphDefs.ListView
showPinButton?: boolean
}
export function Default(props: Props) {
const {view, showPinButton} = props
return (
<Link label={view.name} {...props}>
<Outer>
<Header>
<Avatar src={view.avatar} />
<TitleAndByline
title={view.name}
creator={view.creator}
purpose={view.purpose}
/>
{showPinButton && view.purpose === CURATELIST && (
<SaveButton view={view} pin />
)}
</Header>
<Description description={view.description} />
</Outer>
</Link>
)
}
export function Link({
view,
children,
...props
}: Props & Omit<LinkProps, 'to'>) {
const queryClient = useQueryClient()
const href = React.useMemo(() => {
return createProfileListHref({list: view})
}, [view])
React.useEffect(() => {
precacheList(queryClient, view)
}, [view, queryClient])
return (
<InternalLink to={href} {...props}>
{children}
</InternalLink>
)
}
export function TitleAndByline({
title,
creator,
purpose = CURATELIST,
}: {
title: string
creator?: AppBskyActorDefs.ProfileViewBasic
purpose?: AppBskyGraphDefs.ListView['purpose']
}) {
const t = useTheme()
return (
<View style={[a.flex_1]}>
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
{title}
</Text>
{creator && (
<Text
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{purpose === MODLIST ? (
<Trans>
Moderation list by {sanitizeHandle(creator.handle, '@')}
</Trans>
) : (
<Trans>List by {sanitizeHandle(creator.handle, '@')}</Trans>
)}
</Text>
)}
</View>
)
}
export function createProfileListHref({
list,
}: {
list: AppBskyGraphDefs.ListView
}) {
const urip = new AtUri(list.uri)
const handleOrDid = list.creator.handle || list.creator.did
return `/profile/${handleOrDid}/lists/${urip.rkey}`
}
+169
View File
@@ -0,0 +1,169 @@
import React from 'react'
import {View} from 'react-native'
import {BSKY_LABELER_DID, ModerationCause} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
import {Button} from '#/components/Button'
import {
ModerationDetailsDialog,
useModerationDetailsDialogControl,
} from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
export type CommonProps = {
size?: 'sm' | 'lg'
}
export function Row({
children,
style,
size = 'sm',
}: {children: React.ReactNode | React.ReactNode[]} & CommonProps &
ViewStyleProp) {
const styles = React.useMemo(() => {
switch (size) {
case 'lg':
return [{gap: 5}]
case 'sm':
default:
return [{gap: 3}]
}
}, [size])
return (
<View style={[a.flex_row, a.flex_wrap, a.gap_xs, styles, style]}>
{children}
</View>
)
}
export type LabelProps = {
cause: ModerationCause
disableDetailsDialog?: boolean
noBg?: boolean
} & CommonProps
export function Label({
cause,
size = 'sm',
disableDetailsDialog,
noBg,
}: LabelProps) {
const t = useTheme()
const control = useModerationDetailsDialogControl()
const desc = useModerationCauseDescription(cause)
const isLabeler = Boolean(desc.sourceType && desc.sourceDid)
const isBlueskyLabel =
desc.sourceType === 'labeler' && desc.sourceDid === BSKY_LABELER_DID
const {outer, avi, text} = React.useMemo(() => {
switch (size) {
case 'lg': {
return {
outer: [
t.atoms.bg_contrast_25,
{
gap: 5,
paddingHorizontal: 5,
paddingVertical: 5,
},
],
avi: 16,
text: [a.text_sm],
}
}
case 'sm':
default: {
return {
outer: [
!noBg && t.atoms.bg_contrast_25,
{
gap: 3,
paddingHorizontal: 3,
paddingVertical: 3,
},
],
avi: 12,
text: [a.text_xs],
}
}
}
}, [t, size, noBg])
return (
<>
<Button
disabled={disableDetailsDialog}
label={desc.name}
onPress={e => {
e.preventDefault()
e.stopPropagation()
control.open()
}}>
{({hovered, pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
a.rounded_full,
outer,
(hovered || pressed) && t.atoms.bg_contrast_50,
]}>
{isBlueskyLabel || !isLabeler ? (
<desc.icon
width={avi}
fill={t.atoms.text_contrast_medium.color}
/>
) : (
<UserAvatar avatar={desc.sourceAvi} size={avi} />
)}
<Text
style={[
text,
a.font_semibold,
a.leading_tight,
t.atoms.text_contrast_medium,
{paddingRight: 3},
]}>
{desc.name}
</Text>
</View>
)}
</Button>
{!disableDetailsDialog && (
<ModerationDetailsDialog control={control} modcause={cause} />
)}
</>
)
}
export function FollowsYou({size = 'sm'}: CommonProps) {
const t = useTheme()
const variantStyles = React.useMemo(() => {
switch (size) {
case 'sm':
case 'lg':
default:
return [
{
paddingHorizontal: 6,
paddingVertical: 3,
borderRadius: 4,
},
]
}
}, [size])
return (
<View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_25]}>
<Text style={[a.text_xs, a.leading_tight]}>
<Trans>Follows You</Trans>
</Text>
</View>
)
}
+340 -47
View File
@@ -1,20 +1,33 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {GestureResponderEvent, View} from 'react-native'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' import {
AppBskyActorDefs,
moderateProfile,
ModerationOpts,
RichText as RichTextApi,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {createSanitizedDisplayName} from 'lib/moderation/create-sanitized-display-name' import {LogEvents} from '#/lib/statsig/statsig'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
import {useProfileShadow} from 'state/cache/profile-shadow' import {useProfileShadow} from 'state/cache/profile-shadow'
import {useSession} from 'state/session' import {useSession} from 'state/session'
import {FollowButton} from 'view/com/profile/FollowButton' import * as Toast from '#/view/com/util/Toast'
import {ProfileCardPills} from 'view/com/profile/ProfileCard' import {ProfileCardPills} from 'view/com/profile/ProfileCard'
import {UserAvatar} from 'view/com/util/UserAvatar' import {UserAvatar} from 'view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Link} from '#/components/Link' import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function Default({ export function Default({
profile: profileUnshadowed, profile,
moderationOpts, moderationOpts,
logContext = 'ProfileCard', logContext = 'ProfileCard',
}: { }: {
@@ -22,32 +35,139 @@ export function Default({
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
logContext?: 'ProfileCard' | 'StarterPackProfilesList' logContext?: 'ProfileCard' | 'StarterPackProfilesList'
}) { }) {
const t = useTheme() return (
const {currentAccount, hasSession} = useSession() <Link did={profile.did}>
<Card
profile={profile}
moderationOpts={moderationOpts}
logContext={logContext}
/>
</Link>
)
}
const profile = useProfileShadow(profileUnshadowed) export function Card({
const name = createSanitizedDisplayName(profile) profile,
const handle = `@${sanitizeHandle(profile.handle)}` moderationOpts,
logContext = 'ProfileCard',
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
}) {
const moderation = moderateProfile(profile, moderationOpts)
return (
<Outer>
<Header>
<Avatar profile={profile} moderationOpts={moderationOpts} />
<NameAndHandle profile={profile} moderationOpts={moderationOpts} />
<FollowButton
profile={profile}
moderationOpts={moderationOpts}
logContext={logContext}
/>
</Header>
<ProfileCardPills
followedBy={Boolean(profile.viewer?.followedBy)}
moderation={moderation}
/>
<Description profile={profile} />
</Outer>
)
}
export function Outer({
children,
}: {
children: React.ReactElement | React.ReactElement[]
}) {
return <View style={[a.w_full, a.flex_1, a.gap_xs]}>{children}</View>
}
export function Header({
children,
}: {
children: React.ReactElement | React.ReactElement[]
}) {
return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
}
export function Link({
did,
children,
style,
...rest
}: {did: string} & Omit<LinkProps, 'to'>) {
return (
<InternalLink
to={{
screen: 'Profile',
params: {name: did},
}}
style={[a.flex_col, style]}
{...rest}>
{children}
</InternalLink>
)
}
export function Avatar({
profile,
moderationOpts,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts
}) {
const moderation = moderateProfile(profile, moderationOpts) const moderation = moderateProfile(profile, moderationOpts)
return ( return (
<Wrapper did={profile.did}>
<View style={[a.flex_row, a.gap_sm]}>
<UserAvatar <UserAvatar
size={42} size={42}
avatar={profile.avatar} avatar={profile.avatar}
type={ type={profile.associated?.labeler ? 'labeler' : 'user'}
profile.associated?.labeler
? 'labeler'
: profile.associated?.feedgens
? 'algo'
: 'user'
}
moderation={moderation.ui('avatar')} moderation={moderation.ui('avatar')}
/> />
)
}
export function AvatarPlaceholder() {
const t = useTheme()
return (
<View
style={[
a.rounded_full,
t.atoms.bg_contrast_50,
{
width: 42,
height: 42,
},
]}
/>
)
}
export function NameAndHandle({
profile,
moderationOpts,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts
}) {
const t = useTheme()
const moderation = moderateProfile(profile, moderationOpts)
const name = sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)
const handle = sanitizeHandle(profile.handle, '@')
return (
<View style={[a.flex_1]}> <View style={[a.flex_1]}>
<Text <Text
style={[a.text_md, a.font_bold, a.leading_snug]} style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]}
numberOfLines={1}> numberOfLines={1}>
{name} {name}
</Text> </Text>
@@ -57,35 +177,208 @@ export function Default({
{handle} {handle}
</Text> </Text>
</View> </View>
{hasSession && profile.did !== currentAccount?.did && (
<View style={[a.justify_center, {marginLeft: 'auto'}]}>
<FollowButton profile={profile} logContext={logContext} />
</View>
)}
</View>
<View style={[a.mb_xs]}>
<ProfileCardPills
followedBy={Boolean(profile.viewer?.followedBy)}
moderation={moderation}
/>
</View>
{profile.description && (
<Text numberOfLines={3} style={[a.leading_snug]}>
{profile.description}
</Text>
)}
</Wrapper>
) )
} }
function Wrapper({did, children}: {did: string; children: React.ReactNode}) { export function NameAndHandlePlaceholder() {
const t = useTheme()
return ( return (
<Link <View style={[a.flex_1, a.gap_xs]}>
to={{ <View
screen: 'Profile', style={[
params: {name: did}, a.rounded_xs,
}}> t.atoms.bg_contrast_50,
<View style={[a.flex_1, a.gap_xs]}>{children}</View> {
</Link> width: '60%',
height: 14,
},
]}
/>
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_50,
{
width: '40%',
height: 10,
},
]}
/>
</View>
)
}
export function Description({
profile: profileUnshadowed,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
}) {
const profile = useProfileShadow(profileUnshadowed)
const {description} = profile
const rt = React.useMemo(() => {
if (!description) return
const rt = new RichTextApi({text: description || ''})
rt.detectFacetsWithoutResolution()
return rt
}, [description])
if (!rt) return null
if (
profile.viewer &&
(profile.viewer.blockedBy ||
profile.viewer.blocking ||
profile.viewer.blockingByList)
)
return null
return (
<View style={[a.pt_xs]}>
<RichText
value={rt}
style={[a.leading_snug]}
numberOfLines={3}
disableLinks
/>
</View>
)
}
export function DescriptionPlaceholder() {
const t = useTheme()
return (
<View style={[a.gap_xs]}>
<View
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
/>
<View
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
/>
<View
style={[
a.rounded_xs,
a.w_full,
t.atoms.bg_contrast_50,
{height: 12, width: 100},
]}
/>
</View>
)
}
export type FollowButtonProps = {
profile: AppBskyActorDefs.ProfileViewBasic
moderationOpts: ModerationOpts
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext']
} & Partial<ButtonProps>
export function FollowButton(props: FollowButtonProps) {
const {currentAccount, hasSession} = useSession()
const isMe = props.profile.did === currentAccount?.did
return hasSession && !isMe ? <FollowButtonInner {...props} /> : null
}
export function FollowButtonInner({
profile: profileUnshadowed,
moderationOpts,
logContext,
...rest
}: FollowButtonProps) {
const {_} = useLingui()
const profile = useProfileShadow(profileUnshadowed)
const moderation = moderateProfile(profile, moderationOpts)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
logContext,
)
const isRound = Boolean(rest.shape && rest.shape === 'round')
const onPressFollow = async (e: GestureResponderEvent) => {
e.preventDefault()
e.stopPropagation()
try {
await queueFollow()
Toast.show(
_(
msg`Following ${sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)}`,
),
)
} catch (e: any) {
if (e?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`))
}
}
}
const onPressUnfollow = async (e: GestureResponderEvent) => {
e.preventDefault()
e.stopPropagation()
try {
await queueUnfollow()
Toast.show(
_(
msg`No longer following ${sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)}`,
),
)
} catch (e: any) {
if (e?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`))
}
}
}
const unfollowLabel = _(
msg({
message: 'Following',
comment: 'User is following this account, click to unfollow',
}),
)
const followLabel = _(
msg({
message: 'Follow',
comment: 'User is not following this account, click to follow',
}),
)
if (!profile.viewer) return null
if (
profile.viewer.blockedBy ||
profile.viewer.blocking ||
profile.viewer.blockingByList
)
return null
return (
<View>
{profile.viewer.following ? (
<Button
label={unfollowLabel}
size="small"
variant="solid"
color="secondary"
{...rest}
onPress={onPressUnfollow}>
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
{isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
</Button>
) : (
<Button
label={followLabel}
size="small"
variant="solid"
color="primary"
{...rest}
onPress={onPressFollow}>
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
{isRound ? null : <ButtonText>{followLabel}</ButtonText>}
</Button>
)}
</View>
) )
} }
@@ -29,10 +29,10 @@ import {
} from '#/components/KnownFollowers' } from '#/components/KnownFollowers'
import {InlineLinkText, Link} from '#/components/Link' import {InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as Pills from '#/components/Pills'
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {ProfileLabel} from '../moderation/ProfileHeaderAlerts'
import {ProfileHoverCardProps} from './types' import {ProfileHoverCardProps} from './types'
const floatingMiddlewares = [ const floatingMiddlewares = [
@@ -462,7 +462,8 @@ function Inner({
<Link to={profileURL} label={_(msg`View profile`)} onPress={hide}> <Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
<View style={[a.pb_sm, a.flex_1]}> <View style={[a.pb_sm, a.flex_1]}>
<Text style={[a.pt_md, a.pb_xs, a.text_lg, a.font_bold]}> <Text
style={[a.pt_md, a.pb_xs, a.text_lg, a.font_bold, a.self_start]}>
{sanitizeDisplayName( {sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle), profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'), moderation.ui('displayName'),
@@ -476,8 +477,9 @@ function Inner({
{isBlockedUser && ( {isBlockedUser && (
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}> <View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
{moderation.ui('profileView').alerts.map(cause => ( {moderation.ui('profileView').alerts.map(cause => (
<ProfileLabel <Pills.Label
key={getModerationCauseKey(cause)} key={getModerationCauseKey(cause)}
size="lg"
cause={cause} cause={cause}
disableDetailsDialog disableDetailsDialog
/> />
+61
View File
@@ -0,0 +1,61 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
useProgressGuide,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
import {ProgressGuideTask} from './Task'
export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
const t = useTheme()
const {_} = useLingui()
const guide = useProgressGuide('like-10-and-follow-7')
const {endProgressGuide} = useProgressGuideControls()
if (guide) {
return (
<View style={[a.flex_col, a.gap_md, style]}>
<View style={[a.flex_row, a.align_center, a.justify_between]}>
<Text
style={[
t.atoms.text_contrast_medium,
a.font_semibold,
a.text_sm,
{textTransform: 'uppercase'},
]}>
<Trans>Getting started</Trans>
</Text>
<Button
variant="ghost"
size="tiny"
color="secondary"
shape="round"
label={_(msg`Dismiss getting started guide`)}
onPress={endProgressGuide}>
<ButtonIcon icon={Times} size="sm" />
</Button>
</View>
<ProgressGuideTask
current={guide.numLikes + 1}
total={10 + 1}
title={_(msg`Like 10 posts`)}
subtitle={_(msg`Teach our algorithm what you like`)}
/>
<ProgressGuideTask
current={guide.numFollows + 1}
total={7 + 1}
title={_(msg`Follow 7 accounts`)}
subtitle={_(msg`Bluesky is better with friends!`)}
/>
</View>
)
}
return null
}
+50
View File
@@ -0,0 +1,50 @@
import React from 'react'
import {View} from 'react-native'
import * as Progress from 'react-native-progress'
import {atoms as a, useTheme} from '#/alf'
import {AnimatedCheck} from '../anim/AnimatedCheck'
import {Text} from '../Typography'
export function ProgressGuideTask({
current,
total,
title,
subtitle,
}: {
current: number
total: number
title: string
subtitle?: string
}) {
const t = useTheme()
return (
<View style={[a.flex_row, a.gap_sm, !subtitle && a.align_center]}>
{current === total ? (
<AnimatedCheck playOnMount fill={t.palette.primary_500} width={20} />
) : (
<Progress.Circle
progress={current / total}
color={t.palette.primary_400}
size={20}
thickness={3}
borderWidth={0}
unfilledColor={t.palette.contrast_50}
/>
)}
<View style={[a.flex_col, a.gap_2xs, {marginTop: -2}]}>
<Text style={[a.text_sm, a.font_semibold, a.leading_tight]}>
{title}
</Text>
{subtitle && (
<Text
style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_tight]}>
{subtitle}
</Text>
)}
</View>
</View>
)
}
+169
View File
@@ -0,0 +1,169 @@
import React, {useImperativeHandle} from 'react'
import {Pressable, useWindowDimensions, View} from 'react-native'
import Animated, {
Easing,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isWeb} from '#/platform/detection'
import {atoms as a, useTheme} from '#/alf'
import {Portal} from '#/components/Portal'
import {AnimatedCheck, AnimatedCheckRef} from '../anim/AnimatedCheck'
import {Text} from '../Typography'
export interface ProgressGuideToastRef {
open(): void
close(): void
}
export interface ProgressGuideToastProps {
title: string
subtitle?: string
visibleDuration?: number // default 5s
}
export const ProgressGuideToast = React.forwardRef<
ProgressGuideToastRef,
ProgressGuideToastProps
>(function ProgressGuideToast({title, subtitle, visibleDuration}, ref) {
const t = useTheme()
const {_} = useLingui()
const insets = useSafeAreaInsets()
const [isOpen, setIsOpen] = React.useState(false)
const translateY = useSharedValue(0)
const opacity = useSharedValue(0)
const animatedCheckRef = React.useRef<AnimatedCheckRef | null>(null)
const timeoutRef = React.useRef<NodeJS.Timeout | undefined>()
const winDim = useWindowDimensions()
/**
* Methods
*/
const close = React.useCallback(() => {
// clear the timeout, in case this was called imperatively
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = undefined
}
// animate the opacity then set isOpen to false when done
const setIsntOpen = () => setIsOpen(false)
opacity.value = withTiming(
0,
{
duration: 400,
easing: Easing.out(Easing.cubic),
},
() => runOnJS(setIsntOpen)(),
)
}, [setIsOpen, opacity])
const open = React.useCallback(() => {
// set isOpen=true to render
setIsOpen(true)
// animate the vertical translation, the opacity, and the checkmark
const playCheckmark = () => animatedCheckRef.current?.play()
opacity.value = 0
opacity.value = withTiming(
1,
{
duration: 100,
easing: Easing.out(Easing.cubic),
},
() => runOnJS(playCheckmark)(),
)
translateY.value = 0
translateY.value = withTiming(insets.top + 10, {
duration: 500,
easing: Easing.out(Easing.cubic),
})
// start the countdown timer to autoclose
timeoutRef.current = setTimeout(close, visibleDuration || 5e3)
}, [setIsOpen, translateY, opacity, insets, close, visibleDuration])
useImperativeHandle(
ref,
() => ({
open,
close,
}),
[open, close],
)
const containerStyle = React.useMemo(() => {
let left = 10
let right = 10
if (isWeb && winDim.width > 400) {
left = right = (winDim.width - 380) / 2
}
return {
position: isWeb ? 'fixed' : 'absolute',
top: 0,
left,
right,
}
}, [winDim.width])
const animatedStyle = useAnimatedStyle(() => ({
transform: [{translateY: translateY.value}],
opacity: opacity.value,
}))
return (
isOpen && (
<Portal>
<Animated.View
style={[
// @ts-ignore position: fixed is web only
containerStyle,
animatedStyle,
]}>
<Pressable
style={[
t.atoms.bg,
a.flex_row,
a.align_center,
a.gap_md,
a.border,
t.atoms.border_contrast_high,
a.rounded_md,
a.px_lg,
a.py_md,
a.shadow_sm,
{
shadowRadius: 8,
shadowOpacity: 0.1,
shadowOffset: {width: 0, height: 2},
elevation: 8,
},
]}
onPress={close}
accessibilityLabel={_(msg`Tap to dismiss`)}
accessibilityHint="">
<AnimatedCheck
fill={t.palette.primary_500}
ref={animatedCheckRef}
/>
<View>
<Text style={[a.text_md, a.font_semibold]}>{title}</Text>
{subtitle && (
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{subtitle}
</Text>
)}
</View>
</Pressable>
</Animated.View>
</Portal>
)
)
})
+21 -4
View File
@@ -6,6 +6,7 @@ import {useLingui} from '@lingui/react'
import {getLabelingServiceTitle} from '#/lib/moderation' import {getLabelingServiceTitle} from '#/lib/moderation'
import {ReportOption} from '#/lib/moderation/useReportOptions' import {ReportOption} from '#/lib/moderation/useReportOptions'
import {useGate} from '#/lib/statsig/statsig'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
@@ -36,6 +37,7 @@ export function SubmitView({
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const agent = useAgent() const agent = useAgent()
const gate = useGate()
const [details, setDetails] = React.useState<string>('') const [details, setDetails] = React.useState<string>('')
const [submitting, setSubmitting] = React.useState<boolean>(false) const [submitting, setSubmitting] = React.useState<boolean>(false)
const [selectedServices, setSelectedServices] = React.useState<string[]>([ const [selectedServices, setSelectedServices] = React.useState<string[]>([
@@ -60,15 +62,29 @@ export function SubmitView({
reason: details, reason: details,
} }
const results = await Promise.all( const results = await Promise.all(
selectedServices.map(did => selectedServices.map(did => {
agent if (gate('session_withproxy_fix')) {
return agent
.createModerationReport(report, {
encoding: 'application/json',
headers: {
'atproto-proxy': `${did}#atproto_labeler`,
},
})
.then(
_ => true,
_ => false,
)
} else {
return agent
.withProxy('atproto_labeler', did) .withProxy('atproto_labeler', did)
.createModerationReport(report) .createModerationReport(report)
.then( .then(
_ => true, _ => true,
_ => false, _ => false,
), )
), }
}),
) )
setSubmitting(false) setSubmitting(false)
@@ -92,6 +108,7 @@ export function SubmitView({
onSubmitComplete, onSubmitComplete,
setError, setError,
agent, agent,
gate,
]) ])
return ( return (
+14 -12
View File
@@ -17,6 +17,19 @@ import {Text, TextProps} from '#/components/Typography'
const WORD_WRAP = {wordWrap: 1} const WORD_WRAP = {wordWrap: 1}
export type RichTextProps = TextStyleProp &
Pick<TextProps, 'selectable'> & {
value: RichTextAPI | string
testID?: string
numberOfLines?: number
disableLinks?: boolean
enableTags?: boolean
authorHandle?: string
onLinkPress?: LinkProps['onPress']
interactiveStyle?: TextStyle
emojiMultiplier?: number
}
export function RichText({ export function RichText({
testID, testID,
value, value,
@@ -29,18 +42,7 @@ export function RichText({
onLinkPress, onLinkPress,
interactiveStyle, interactiveStyle,
emojiMultiplier = 1.85, emojiMultiplier = 1.85,
}: TextStyleProp & }: RichTextProps) {
Pick<TextProps, 'selectable'> & {
value: RichTextAPI | string
testID?: string
numberOfLines?: number
disableLinks?: boolean
enableTags?: boolean
authorHandle?: string
onLinkPress?: LinkProps['onPress']
interactiveStyle?: TextStyle
emojiMultiplier?: number
}) {
const richText = React.useMemo( const richText = React.useMemo(
() => () =>
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}), value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
@@ -45,7 +45,7 @@ export const FeedsList = React.forwardRef<SectionRef, ProfilesListProps>(
(isWeb || index !== 0) && a.border_t, (isWeb || index !== 0) && a.border_t,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
]}> ]}>
<FeedCard.Default type="feed" view={item} /> <FeedCard.Default view={item} />
</View> </View>
) )
} }
@@ -0,0 +1,51 @@
import React, {useCallback} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {isNative} from 'platform/detection'
import {Feed} from 'view/com/posts/Feed'
import {EmptyState} from 'view/com/util/EmptyState'
import {ListRef} from 'view/com/util/List'
import {SectionRef} from '#/screens/Profile/Sections/types'
interface ProfilesListProps {
listUri: string
headerHeight: number
scrollElRef: ListRef
}
export const PostsList = React.forwardRef<SectionRef, ProfilesListProps>(
function PostsListImpl({listUri, headerHeight, scrollElRef}, ref) {
const feed: FeedDescriptor = `list|${listUri}|as_following`
const {_} = useLingui()
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({
animated: isNative,
offset: -headerHeight,
})
}, [scrollElRef, headerHeight])
React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
const renderPostsEmpty = useCallback(() => {
return <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} />
}, [_])
return (
<View>
<Feed
feed={feed}
pollInterval={60e3}
scrollElRef={scrollElRef}
renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight}
/>
</View>
)
},
)
@@ -9,11 +9,14 @@ import {
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query' import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset' import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
import {isBlockedOrBlocking} from 'lib/moderation/blocked-and-muted'
import {isNative, isWeb} from 'platform/detection' import {isNative, isWeb} from 'platform/detection'
import {useListMembersQuery} from 'state/queries/list-members'
import {useSession} from 'state/session' import {useSession} from 'state/session'
import {List, ListRef} from 'view/com/util/List' import {List, ListRef} from 'view/com/util/List'
import {SectionRef} from '#/screens/Profile/Sections/types' import {SectionRef} from '#/screens/Profile/Sections/types'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {ListMaybePlaceholder} from '#/components/Lists'
import {Default as ProfileCard} from '#/components/ProfileCard' import {Default as ProfileCard} from '#/components/ProfileCard'
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) { function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
@@ -32,22 +35,21 @@ interface ProfilesListProps {
export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>( export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
function ProfilesListImpl( function ProfilesListImpl(
{listUri, listMembersQuery, moderationOpts, headerHeight, scrollElRef}, {listUri, moderationOpts, headerHeight, scrollElRef},
ref, ref,
) { ) {
const t = useTheme() const t = useTheme()
const [initialHeaderHeight] = React.useState(headerHeight) const [initialHeaderHeight] = React.useState(headerHeight)
const bottomBarOffset = useBottomBarOffset(20) const bottomBarOffset = useBottomBarOffset(20)
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {data, refetch, isError} = useListMembersQuery(listUri, 50)
const [isPTRing, setIsPTRing] = React.useState(false) const [isPTRing, setIsPTRing] = React.useState(false)
const {data, refetch} = listMembersQuery
// The server returns these sorted by descending creation date, so we want to invert // The server returns these sorted by descending creation date, so we want to invert
const profiles = data?.pages const profiles = data?.pages
.flatMap(p => p.items.map(i => i.subject)) .flatMap(p => p.items.map(i => i.subject))
.filter(p => !p.associated?.labeler) .filter(p => !isBlockedOrBlocking(p) && !p.associated?.labeler)
.reverse() .reverse()
const isOwn = new AtUri(listUri).host === currentAccount?.did const isOwn = new AtUri(listUri).host === currentAccount?.did
@@ -95,7 +97,19 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
) )
} }
if (listMembersQuery) if (!data) {
return (
<View style={{marginTop: headerHeight, marginBottom: bottomBarOffset}}>
<ListMaybePlaceholder
isLoading={true}
isError={isError}
onRetry={refetch}
/>
</View>
)
}
if (data)
return ( return (
<List <List
data={getSortedProfiles()} data={getSortedProfiles()}
@@ -232,11 +232,13 @@ function Empty() {
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
{color: 'white'}, {color: 'white'},
]}> ]}>
You haven't created a starter pack yet! <Trans>You haven't created a starter pack yet!</Trans>
</Text> </Text>
<Text style={[a.text_md, {color: 'white'}]}> <Text style={[a.text_md, {color: 'white'}]}>
Starter packs let you easily share your favorite feeds and people with <Trans>
your friends. Starter packs let you easily share your favorite feeds and people
with your friends.
</Trans>
</Text> </Text>
</View> </View>
<View style={[a.flex_row, a.gap_md, {marginLeft: 'auto'}]}> <View style={[a.flex_row, a.gap_md, {marginLeft: 'auto'}]}>
+1 -1
View File
@@ -110,7 +110,7 @@ export function QrCodeInner({link}: {link: string}) {
innerEyesOptions={{borderRadius: 3}} innerEyesOptions={{borderRadius: 3}}
logo={{ logo={{
href: require('../../../assets/logo.png'), href: require('../../../assets/logo.png'),
scale: 1.2, scale: 0.95,
padding: 2, padding: 2,
hidePieces: true, hidePieces: true,
}} }}
+50 -10
View File
@@ -1,15 +1,20 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {Image} from 'expo-image'
import {AppBskyGraphStarterpack, AtUri} from '@atproto/api' import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs' import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
import {getStarterPackOgCard} from 'lib/strings/starter-pack'
import {precacheResolvedUri} from 'state/queries/resolve-uri'
import {precacheStarterPack} from 'state/queries/starter-packs'
import {useSession} from 'state/session' import {useSession} from 'state/session'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {StarterPack} from '#/components/icons/StarterPack' import {StarterPack} from '#/components/icons/StarterPack'
import {Link as InternalLink, LinkProps} from '#/components/Link' import {BaseLink} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) { export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
@@ -88,10 +93,13 @@ export function Card({
export function Link({ export function Link({
starterPack, starterPack,
children, children,
...rest
}: { }: {
starterPack: StarterPackViewBasic starterPack: StarterPackViewBasic
} & Omit<LinkProps, 'to'>) { onPress?: () => void
children: React.ReactNode
}) {
const {_} = useLingui()
const queryClient = useQueryClient()
const {record} = starterPack const {record} = starterPack
const {rkey, handleOrDid} = React.useMemo(() => { const {rkey, handleOrDid} = React.useMemo(() => {
const rkey = new AtUri(starterPack.uri).rkey const rkey = new AtUri(starterPack.uri).rkey
@@ -104,14 +112,46 @@ export function Link({
} }
return ( return (
<InternalLink <BaseLink
label={record.name} action="push"
{...rest} to={`/starter-pack/${handleOrDid}/${rkey}`}
to={{ label={_(msg`Navigate to ${record.name}`)}
screen: 'StarterPack', onPress={() => {
params: {name: handleOrDid, rkey}, precacheResolvedUri(
queryClient,
starterPack.creator.handle,
starterPack.creator.did,
)
precacheStarterPack(queryClient, starterPack)
}}> }}>
{children} {children}
</InternalLink> </BaseLink>
)
}
export function Embed({starterPack}: {starterPack: StarterPackViewBasic}) {
const t = useTheme()
const imageUri = getStarterPackOgCard(starterPack)
return (
<View
style={[
a.mt_xs,
a.border,
a.rounded_sm,
a.overflow_hidden,
t.atoms.border_contrast_low,
]}>
<Link starterPack={starterPack}>
<Image
source={imageUri}
style={[a.w_full, {aspectRatio: 1.91}]}
accessibilityIgnoresInvertColors={true}
/>
<View style={[a.px_sm, a.py_md]}>
<Card starterPack={starterPack} />
</View>
</Link>
</View>
) )
} }
@@ -78,7 +78,13 @@ function WizardListCard({
/> />
<View style={[a.flex_1, a.gap_2xs]}> <View style={[a.flex_1, a.gap_2xs]}>
<Text <Text
style={[a.flex_1, a.font_bold, a.text_md, a.leading_tight]} style={[
a.flex_1,
a.font_bold,
a.text_md,
a.leading_tight,
a.self_start,
]}
numberOfLines={1}> numberOfLines={1}>
{displayName} {displayName}
</Text> </Text>
+92
View File
@@ -0,0 +1,92 @@
import React from 'react'
import Animated, {
Easing,
useAnimatedProps,
useSharedValue,
withDelay,
withTiming,
} from 'react-native-reanimated'
import Svg, {Circle, Path} from 'react-native-svg'
import {Props, useCommonSVGProps} from '#/components/icons/common'
const AnimatedPath = Animated.createAnimatedComponent(Path)
const AnimatedCircle = Animated.createAnimatedComponent(Circle)
const PATH = 'M14.1 27.2l7.1 7.2 16.7-16.8'
export interface AnimatedCheckRef {
play(cb?: () => void): void
}
export interface AnimatedCheckProps extends Props {
playOnMount?: boolean
}
export const AnimatedCheck = React.forwardRef<
AnimatedCheckRef,
AnimatedCheckProps
>(function AnimatedCheck({playOnMount, ...props}, ref) {
const {fill, size, style, ...rest} = useCommonSVGProps(props)
const circleAnim = useSharedValue(0)
const checkAnim = useSharedValue(0)
const circleAnimatedProps = useAnimatedProps(() => ({
strokeDashoffset: 166 - circleAnim.value * 166,
}))
const checkAnimatedProps = useAnimatedProps(() => ({
strokeDashoffset: 48 - 48 * checkAnim.value,
}))
const play = React.useCallback(
(cb?: () => void) => {
circleAnim.value = 0
checkAnim.value = 0
circleAnim.value = withTiming(1, {duration: 500, easing: Easing.linear})
checkAnim.value = withDelay(
500,
withTiming(1, {duration: 300, easing: Easing.linear}, cb),
)
},
[circleAnim, checkAnim],
)
React.useImperativeHandle(ref, () => ({
play,
}))
React.useEffect(() => {
if (playOnMount) {
play()
}
}, [play, playOnMount])
return (
<Svg
fill="none"
{...rest}
viewBox="0 0 52 52"
width={size}
height={size}
style={style}>
<AnimatedCircle
animatedProps={circleAnimatedProps}
cx="26"
cy="26"
r="24"
fill="none"
stroke={fill}
strokeWidth={4}
strokeDasharray={166}
/>
<AnimatedPath
animatedProps={checkAnimatedProps}
stroke={fill}
d={PATH}
strokeWidth={4}
strokeDasharray={48}
/>
</Svg>
)
})
+3 -4
View File
@@ -357,12 +357,11 @@ function TargetToggle({children}: React.PropsWithChildren<{}>) {
a.px_sm, a.px_sm,
gtMobile && a.px_md, gtMobile && a.px_md,
a.rounded_sm, a.rounded_sm,
t.atoms.bg_contrast_50, t.atoms.bg_contrast_25,
(ctx.hovered || ctx.focused) && t.atoms.bg_contrast_100, (ctx.hovered || ctx.focused) && t.atoms.bg_contrast_50,
ctx.selected && [ ctx.selected && [
{ {
backgroundColor: backgroundColor: t.palette.primary_50,
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_975,
}, },
], ],
ctx.disabled && { ctx.disabled && {
+1 -4
View File
@@ -196,10 +196,7 @@ function Selectable({
t.atoms.bg_contrast_50, t.atoms.bg_contrast_50,
(hovered || focused) && t.atoms.bg_contrast_100, (hovered || focused) && t.atoms.bg_contrast_100,
isSelected && { isSelected && {
backgroundColor: backgroundColor: t.palette.primary_100,
t.name === 'light'
? t.palette.primary_50
: t.palette.primary_975,
}, },
style, style,
]}> ]}>
+2 -8
View File
@@ -72,8 +72,7 @@ let MessageItem = ({
lastInGroupRef.current = isLastInGroup lastInGroupRef.current = isLastInGroup
} }
const pendingColor = const pendingColor = t.palette.primary_200
t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800
const rt = useMemo(() => { const rt = useMemo(() => {
return new RichTextAPI({text: message.text, facets: message.facets}) return new RichTextAPI({text: message.text, facets: message.facets})
@@ -110,12 +109,7 @@ let MessageItem = ({
}> }>
<RichText <RichText
value={rt} value={rt}
style={[ style={[a.text_md, isFromSelf && {color: t.palette.white}]}
a.text_md,
isFromSelf && {color: t.palette.white},
isPending &&
t.name !== 'light' && {color: t.palette.primary_300},
]}
interactiveStyle={a.underline} interactiveStyle={a.underline}
enableTags enableTags
emojiMultiplier={3} emojiMultiplier={3}
+7 -2
View File
@@ -168,7 +168,12 @@ function HeaderReady({
</View> </View>
<View style={a.flex_1}> <View style={a.flex_1}>
<Text <Text
style={[a.text_md, a.font_bold, web(a.leading_normal)]} style={[
a.text_md,
a.font_bold,
a.self_start,
web(a.leading_normal),
]}
numberOfLines={1}> numberOfLines={1}>
{displayName} {displayName}
</Text> </Text>
@@ -214,7 +219,7 @@ function HeaderReady({
]}> ]}>
<PostAlerts <PostAlerts
modui={moderation.ui('contentList')} modui={moderation.ui('contentList')}
size="large" size="lg"
style={[a.pt_xs]} style={[a.pt_xs]}
/> />
</View> </View>
@@ -395,7 +395,7 @@ function ProfileCard({
/> />
<View style={[a.flex_1, a.gap_2xs]}> <View style={[a.flex_1, a.gap_2xs]}>
<Text <Text
style={[t.atoms.text, a.font_bold, a.leading_tight]} style={[t.atoms.text, a.font_bold, a.leading_tight, a.self_start]}
numberOfLines={1}> numberOfLines={1}>
{displayName} {displayName}
</Text> </Text>
+3 -6
View File
@@ -101,16 +101,13 @@ export function useSharedInputStyles() {
] ]
const error: ViewStyle[] = [ const error: ViewStyle[] = [
{ {
backgroundColor: backgroundColor: t.palette.negative_25,
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900, borderColor: t.palette.negative_300,
borderColor:
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
}, },
] ]
const errorHover: ViewStyle[] = [ const errorHover: ViewStyle[] = [
{ {
backgroundColor: backgroundColor: t.palette.negative_25,
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor: t.palette.negative_500, borderColor: t.palette.negative_500,
}, },
] ]
+7 -14
View File
@@ -281,24 +281,20 @@ export function createSharedToggleStyles({
if (selected) { if (selected) {
base.push({ base.push({
backgroundColor: backgroundColor: t.palette.primary_25,
t.name === 'light' ? t.palette.primary_25 : t.palette.primary_900,
borderColor: t.palette.primary_500, borderColor: t.palette.primary_500,
}) })
if (hovered) { if (hovered) {
baseHover.push({ baseHover.push({
backgroundColor: backgroundColor: t.palette.primary_100,
t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800, borderColor: t.palette.primary_600,
borderColor:
t.name === 'light' ? t.palette.primary_600 : t.palette.primary_400,
}) })
} }
} else { } else {
if (hovered) { if (hovered) {
baseHover.push({ baseHover.push({
backgroundColor: backgroundColor: t.palette.contrast_50,
t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100,
borderColor: t.palette.contrast_500, borderColor: t.palette.contrast_500,
}) })
} }
@@ -306,16 +302,13 @@ export function createSharedToggleStyles({
if (isInvalid) { if (isInvalid) {
base.push({ base.push({
backgroundColor: backgroundColor: t.palette.negative_25,
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_975, borderColor: t.palette.negative_300,
borderColor:
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
}) })
if (hovered) { if (hovered) {
baseHover.push({ baseHover.push({
backgroundColor: backgroundColor: t.palette.negative_25,
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor: t.palette.negative_600, borderColor: t.palette.negative_600,
}) })
} }
+4
View File
@@ -8,6 +8,10 @@ export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z', path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z',
}) })
export const ArrowRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M21 12a1 1 0 0 1-.293.707l-6 6a1 1 0 0 1-1.414-1.414L17.586 13H4a1 1 0 1 1 0-2h13.586l-4.293-4.293a1 1 0 0 1 1.414-1.414l6 6A1 1 0 0 1 21 12Z',
})
export const ArrowBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const ArrowBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z', path: 'M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z',
}) })
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const VideoClip_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v2h2V5H5Zm4 0v6h6V5H9Zm8 0v2h2V5h-2Zm2 4h-2v2h2V9Zm0 4h-2v2.444h2V13Zm0 4.444h-2V19h2v-1.556ZM15 19v-6H9v6h6Zm-8 0v-2H5v2h2Zm-2-4h2v-2H5v2Zm0-4h2V9H5v2Z',
})
+3 -2
View File
@@ -4,7 +4,7 @@ import type {PathProps, SvgProps} from 'react-native-svg'
import {Defs, LinearGradient, Stop} from 'react-native-svg' import {Defs, LinearGradient, Stop} from 'react-native-svg'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {tokens} from '#/alf' import {tokens, useTheme} from '#/alf'
export type Props = { export type Props = {
fill?: PathProps['fill'] fill?: PathProps['fill']
@@ -22,10 +22,11 @@ export const sizes = {
} }
export function useCommonSVGProps(props: Props) { export function useCommonSVGProps(props: Props) {
const t = useTheme()
const {fill, size, gradient, ...rest} = props const {fill, size, gradient, ...rest} = props
const style = StyleSheet.flatten(rest.style) const style = StyleSheet.flatten(rest.style)
const _size = Number(size ? sizes[size] : rest.width || sizes.md) const _size = Number(size ? sizes[size] : rest.width || sizes.md)
let _fill = fill || style?.color || tokens.color.blue_500 let _fill = fill || style?.color || t.palette.primary_500
let gradientDef = null let gradientDef = null
if (gradient && tokens.gradients[gradient]) { if (gradient && tokens.gradients[gradient]) {
+1 -3
View File
@@ -165,9 +165,7 @@ export function ContentHider({
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {},
overflow: 'hidden',
},
cover: { cover: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -7,6 +7,7 @@ import {useMutation} from '@tanstack/react-query'
import {useLabelInfo} from '#/lib/moderation/useLabelInfo' import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {useGate} from '#/lib/statsig/statsig'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
@@ -201,12 +202,31 @@ function AppealForm({
const [details, setDetails] = React.useState('') const [details, setDetails] = React.useState('')
const isAccountReport = 'did' in subject const isAccountReport = 'did' in subject
const agent = useAgent() const agent = useAgent()
const gate = useGate()
const {mutate, isPending} = useMutation({ const {mutate, isPending} = useMutation({
mutationFn: async () => { mutationFn: async () => {
const $type = !isAccountReport const $type = !isAccountReport
? 'com.atproto.repo.strongRef' ? 'com.atproto.repo.strongRef'
: 'com.atproto.admin.defs#repoRef' : 'com.atproto.admin.defs#repoRef'
if (gate('session_withproxy_fix')) {
await agent.createModerationReport(
{
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
subject: {
$type,
...subject,
},
reason: details,
},
{
encoding: 'application/json',
headers: {
'atproto-proxy': `${label.src}#atproto_labeler`,
},
},
)
} else {
await agent await agent
.withProxy('atproto_labeler', label.src) .withProxy('atproto_labeler', label.src)
.createModerationReport({ .createModerationReport({
@@ -217,6 +237,7 @@ function AppealForm({
}, },
reason: details, reason: details,
}) })
}
}, },
onError: err => { onError: err => {
logger.error('Failed to submit label appeal', {message: err}) logger.error('Failed to submit label appeal', {message: err})
+11 -86
View File
@@ -1,25 +1,17 @@
import React from 'react' import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native' import {StyleProp, ViewStyle} from 'react-native'
import {BSKY_LABELER_DID, ModerationCause, ModerationUI} from '@atproto/api' import {ModerationUI} from '@atproto/api'
import {getModerationCauseKey} from '#/lib/moderation' import {getModerationCauseKey} from '#/lib/moderation'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription' import * as Pills from '#/components/Pills'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {
ModerationDetailsDialog,
useModerationDetailsDialogControl,
} from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
export function PostAlerts({ export function PostAlerts({
modui, modui,
size, size = 'sm',
style, style,
}: { }: {
modui: ModerationUI modui: ModerationUI
size?: 'medium' | 'large' size?: Pills.CommonProps['size']
includeMute?: boolean includeMute?: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
@@ -28,90 +20,23 @@ export function PostAlerts({
} }
return ( return (
<View style={[a.flex_col, a.gap_xs, style]}> <Pills.Row size={size} style={[size === 'sm' && {marginLeft: -3}, style]}>
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
{modui.alerts.map(cause => ( {modui.alerts.map(cause => (
<PostLabel <Pills.Label
key={getModerationCauseKey(cause)} key={getModerationCauseKey(cause)}
cause={cause} cause={cause}
size={size} size={size}
noBg={size === 'sm'}
/> />
))} ))}
{modui.informs.map(cause => ( {modui.informs.map(cause => (
<PostLabel <Pills.Label
key={getModerationCauseKey(cause)} key={getModerationCauseKey(cause)}
cause={cause} cause={cause}
size={size} size={size}
noBg={size === 'sm'}
/> />
))} ))}
</View> </Pills.Row>
</View>
)
}
function PostLabel({
cause,
size,
}: {
cause: ModerationCause
size?: 'medium' | 'large'
}) {
const control = useModerationDetailsDialogControl()
const desc = useModerationCauseDescription(cause)
const t = useTheme()
return (
<>
<Button
label={desc.name}
onPress={e => {
e.preventDefault()
e.stopPropagation()
control.open()
}}>
{({hovered, pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
a.gap_xs,
a.rounded_sm,
hovered || pressed
? size === 'large'
? t.atoms.bg_contrast_50
: t.atoms.bg_contrast_25
: size === 'large'
? t.atoms.bg_contrast_25
: undefined,
size === 'large'
? {paddingLeft: 4, paddingRight: 6, paddingVertical: 2}
: {paddingRight: 4, paddingVertical: 1},
]}>
{desc.sourceType === 'labeler' &&
desc.sourceDid !== BSKY_LABELER_DID ? (
<UserAvatar
avatar={desc.sourceAvi}
size={size === 'large' ? 16 : 12}
type="labeler"
shape="circle"
/>
) : (
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
)}
<Text
style={[
a.text_left,
a.leading_snug,
size === 'large' ? {fontSize: 13} : a.text_xs,
size === 'large' ? t.atoms.text : t.atoms.text_contrast_high,
]}>
{desc.name}
</Text>
</View>
)}
</Button>
<ModerationDetailsDialog control={control} modcause={cause} />
</>
) )
} }
@@ -1,25 +1,12 @@
import React from 'react' import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native' import {StyleProp, ViewStyle} from 'react-native'
import { import {ModerationDecision} from '@atproto/api'
BSKY_LABELER_DID,
ModerationCause,
ModerationDecision,
} from '@atproto/api'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {getModerationCauseKey} from 'lib/moderation' import {getModerationCauseKey} from 'lib/moderation'
import {UserAvatar} from '#/view/com/util/UserAvatar' import * as Pills from '#/components/Pills'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {
ModerationDetailsDialog,
useModerationDetailsDialogControl,
} from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
export function ProfileHeaderAlerts({ export function ProfileHeaderAlerts({
moderation, moderation,
style,
}: { }: {
moderation: ModerationDecision moderation: ModerationDecision
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
@@ -30,73 +17,21 @@ export function ProfileHeaderAlerts({
} }
return ( return (
<View style={[a.flex_col, a.gap_xs, style]}> <Pills.Row size="lg">
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
{modui.alerts.map(cause => ( {modui.alerts.map(cause => (
<ProfileLabel key={getModerationCauseKey(cause)} cause={cause} /> <Pills.Label
size="lg"
key={getModerationCauseKey(cause)}
cause={cause}
/>
))} ))}
{modui.informs.map(cause => ( {modui.informs.map(cause => (
<ProfileLabel key={getModerationCauseKey(cause)} cause={cause} /> <Pills.Label
size="lg"
key={getModerationCauseKey(cause)}
cause={cause}
/>
))} ))}
</View> </Pills.Row>
</View>
)
}
export function ProfileLabel({
cause,
disableDetailsDialog,
}: {
cause: ModerationCause
disableDetailsDialog?: boolean
}) {
const t = useTheme()
const control = useModerationDetailsDialogControl()
const desc = useModerationCauseDescription(cause)
return (
<>
<Button
disabled={disableDetailsDialog}
label={desc.name}
onPress={() => {
control.open()
}}>
{({hovered, pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
{paddingLeft: 6, paddingRight: 8, paddingVertical: 4},
a.gap_xs,
a.rounded_md,
hovered || pressed
? t.atoms.bg_contrast_50
: t.atoms.bg_contrast_25,
]}>
{desc.sourceType === 'labeler' &&
desc.sourceDid !== BSKY_LABELER_DID ? (
<UserAvatar avatar={desc.sourceAvi} size={16} />
) : (
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
)}
<Text
style={[
a.text_left,
a.leading_snug,
a.text_sm,
t.atoms.text_contrast_medium,
a.font_semibold,
]}>
{desc.name}
</Text>
</View>
)}
</Button>
{!disableDetailsDialog && (
<ModerationDetailsDialog control={control} modcause={cause} />
)}
</>
) )
} }
+3 -2
View File
@@ -1,7 +1,8 @@
import React, {ReactNode, createContext, useContext} from 'react' import React, {createContext, ReactNode, useContext} from 'react'
import {TextStyle, ViewStyle} from 'react-native' import {TextStyle, ViewStyle} from 'react-native'
import {ThemeName} from '#/alf/types'
import {darkTheme, defaultTheme, dimTheme} from './themes' import {darkTheme, defaultTheme, dimTheme} from './themes'
import {ThemeName} from '#/alf/themes'
export type ColorScheme = 'light' | 'dark' export type ColorScheme = 'light' | 'dark'
+3 -1
View File
@@ -20,7 +20,7 @@ export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
// code and update this number with each release until we can get the // code and update this number with each release until we can get the
// server route done. // server route done.
// -prf // -prf
export const JOINED_THIS_WEEK = 37115 // as of June24 2024 export const JOINED_THIS_WEEK = 21797 // as of Jul5 2024
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new` const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
export function FEEDBACK_FORM_URL({ export function FEEDBACK_FORM_URL({
@@ -133,3 +133,5 @@ export const GIF_SEARCH = (params: string) =>
`${GIF_SERVICE}/tenor/v2/search?${params}` `${GIF_SERVICE}/tenor/v2/search?${params}`
export const GIF_FEATURED = (params: string) => export const GIF_FEATURED = (params: string) =>
`${GIF_SERVICE}/tenor/v2/featured?${params}` `${GIF_SERVICE}/tenor/v2/featured?${params}`
export const MAX_LABELERS = 20
+13 -1
View File
@@ -1,9 +1,12 @@
import React from 'react' import React from 'react'
import * as Linking from 'expo-linking' import * as Linking from 'expo-linking'
import {logEvent} from 'lib/statsig/statsig'
import {isNative} from 'platform/detection' import {isNative} from 'platform/detection'
import {useComposerControls} from 'state/shell'
import {useSession} from 'state/session' import {useSession} from 'state/session'
import {useComposerControls} from 'state/shell'
import {useCloseAllActiveElements} from 'state/util' import {useCloseAllActiveElements} from 'state/util'
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
type IntentType = 'compose' type IntentType = 'compose'
@@ -15,6 +18,15 @@ export function useIntentHandler() {
React.useEffect(() => { React.useEffect(() => {
const handleIncomingURL = (url: string) => { const handleIncomingURL = (url: string) => {
const referrerInfo = Referrer.getReferrerInfo()
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
logEvent('deepLink:referrerReceived', {
to: url,
referrer: referrerInfo?.referrer,
hostname: referrerInfo?.hostname,
})
}
// We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three // We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three
// slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care // slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care
// of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first // of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first
+29
View File
@@ -48,6 +48,35 @@ export function usePhotoLibraryPermission() {
return {requestPhotoAccessIfNeeded} return {requestPhotoAccessIfNeeded}
} }
export function useVideoLibraryPermission() {
const [res, requestPermission] = MediaLibrary.usePermissions({
granularPermissions: ['video'],
})
const requestVideoAccessIfNeeded = async () => {
// On the, we use <input type="file"> to produce a filepicker
// This does not need any permission granting.
if (isWeb) {
return true
}
if (res?.granted) {
return true
} else if (!res || res.status === 'undetermined' || res?.canAskAgain) {
const {canAskAgain, granted, status} = await requestPermission()
if (!canAskAgain && status === 'undetermined') {
openPermissionAlert('video library')
}
return granted
} else {
openPermissionAlert('video library')
return false
}
}
return {requestVideoAccessIfNeeded}
}
export function useCameraPermission() { export function useCameraPermission() {
const [res, requestPermission] = Camera.useCameraPermissions() const [res, requestPermission] = Camera.useCameraPermissions()
+8
View File
@@ -14,3 +14,11 @@ export function useCameraPermission() {
return {requestCameraAccessIfNeeded} return {requestCameraAccessIfNeeded}
} }
export function useVideoLibraryPermission() {
const requestVideoAccessIfNeeded = async () => {
return true
}
return {requestVideoAccessIfNeeded}
}
+46 -5
View File
@@ -1,11 +1,16 @@
import {AppBskyFeedPost, BskyAgent} from '@atproto/api' import {AppBskyFeedPost, AppBskyGraphStarterpack, BskyAgent} from '@atproto/api'
import {useFetchDid} from '#/state/queries/handle'
import {useGetPost} from '#/state/queries/post'
import * as apilib from 'lib/api/index' import * as apilib from 'lib/api/index'
import {LikelyType, LinkMeta} from './link-meta' import {
createStarterPackUri,
parseStarterPackUri,
} from 'lib/strings/starter-pack'
import {ComposerOptsQuote} from 'state/shell/composer'
// import {match as matchRoute} from 'view/routes' // import {match as matchRoute} from 'view/routes'
import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers' import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
import {ComposerOptsQuote} from 'state/shell/composer' import {LikelyType, LinkMeta} from './link-meta'
import {useGetPost} from '#/state/queries/post'
import {useFetchDid} from '#/state/queries/handle'
// TODO // TODO
// import {Home} from 'view/screens/Home' // import {Home} from 'view/screens/Home'
@@ -174,3 +179,39 @@ export async function getListAsEmbed(
}, },
} }
} }
export async function getStarterPackAsEmbed(
agent: BskyAgent,
fetchDid: ReturnType<typeof useFetchDid>,
url: string,
): Promise<apilib.ExternalEmbedDraft> {
const parsed = parseStarterPackUri(url)
if (!parsed) {
throw new Error(
'Unexepectedly called getStarterPackAsEmbed with a non-starterpack url',
)
}
const did = await fetchDid(parsed.name)
const starterPack = createStarterPackUri({did, rkey: parsed.rkey})
const res = await agent.app.bsky.graph.getStarterPack({starterPack})
const record = res.data.starterPack.record
return {
isLoading: false,
uri: starterPack,
meta: {
url: starterPack,
likelyType: LikelyType.AtpData,
// Validation here should never fail
title: AppBskyGraphStarterpack.isRecord(record)
? record.name
: 'Starter Pack',
},
embed: {
$type: 'app.bsky.embed.record',
record: {
uri: res.data.starterPack.uri,
cid: res.data.starterPack.cid,
},
},
}
}
+5 -3
View File
@@ -1,8 +1,10 @@
import {BskyAgent} from '@atproto/api' import {BskyAgent} from '@atproto/api'
import {isBskyAppUrl} from '../strings/url-helpers'
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' import {getGiphyMetaUri} from 'lib/strings/embed-player'
import {parseStarterPackUri} from 'lib/strings/starter-pack'
import {isBskyAppUrl} from '../strings/url-helpers'
import {extractBskyMeta} from './bsky'
export enum LikelyType { export enum LikelyType {
HTML, HTML,
@@ -28,7 +30,7 @@ export async function getLinkMeta(
url: string, url: string,
timeout = 15e3, timeout = 15e3,
): Promise<LinkMeta> { ): Promise<LinkMeta> {
if (isBskyAppUrl(url)) { if (isBskyAppUrl(url) && !parseStarterPackUri(url)) {
return extractBskyMeta(agent, url) return extractBskyMeta(agent, url)
} }
+27
View File
@@ -0,0 +1,27 @@
import {logger} from '#/logger'
export async function resolveShortLink(shortLink: string) {
const controller = new AbortController()
const to = setTimeout(() => controller.abort(), 2e3)
try {
const res = await fetch(shortLink, {
method: 'GET',
headers: {
Accept: 'application/json',
},
signal: controller.signal,
})
if (res.status !== 200) {
logger.error('Failed to resolve short link', {status: res.status})
return shortLink
}
const json = (await res.json()) as {url: string}
return json.url
} catch (e: unknown) {
logger.error('Failed to resolve short link', {safeMessage: e})
return shortLink
} finally {
clearTimeout(to)
}
}
+30
View File
@@ -0,0 +1,30 @@
import {getVideoMetaData, Video} from 'react-native-compressor'
export type CompressedVideo = {
uri: string
size: number
}
export async function compressVideo(
file: string,
opts?: {
getCancellationId?: (id: string) => void
onProgress?: (progress: number) => void
},
): Promise<CompressedVideo> {
const {onProgress, getCancellationId} = opts || {}
const compressed = await Video.compress(
file,
{
getCancellationId,
compressionMethod: 'manual',
bitrate: 3_000_000, // 3mbps
maxSize: 1920,
},
onProgress,
)
const info = await getVideoMetaData(compressed)
return {uri: compressed, size: info.size}
}
+28
View File
@@ -0,0 +1,28 @@
import {VideoTooLargeError} from 'lib/media/video/errors'
const MAX_VIDEO_SIZE = 1024 * 1024 * 100 // 100MB
export type CompressedVideo = {
uri: string
size: number
}
// doesn't actually compress, but throws if >100MB
export async function compressVideo(
file: string,
_callbacks?: {
onProgress: (progress: number) => void
},
): Promise<CompressedVideo> {
const blob = await fetch(file).then(res => res.blob())
const video = URL.createObjectURL(blob)
if (blob.size > MAX_VIDEO_SIZE) {
throw new VideoTooLargeError()
}
return {
size: blob.size,
uri: video,
}
}
+6
View File
@@ -0,0 +1,6 @@
export class VideoTooLargeError extends Error {
constructor() {
super('Videos cannot be larger than 100MB')
this.name = 'VideoTooLargeError'
}
}
+17
View File
@@ -0,0 +1,17 @@
import {AppBskyActorDefs} from '@atproto/api'
export function isBlockedOrBlocking(
profile:
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileViewDetailed,
) {
return profile.viewer?.blockedBy || profile.viewer?.blocking
}
export function isMuted(
profile:
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileViewDetailed,
) {
return profile.viewer?.muted || profile.viewer?.mutedByList
}
@@ -12,10 +12,6 @@ export function createSanitizedDisplayName(
if (profile.displayName != null && profile.displayName !== '') { if (profile.displayName != null && profile.displayName !== '') {
return sanitizeDisplayName(profile.displayName) return sanitizeDisplayName(profile.displayName)
} else { } else {
let sanitizedHandle = sanitizeHandle(profile.handle) return sanitizeHandle(profile.handle, noAt ? '' : '@')
if (!noAt) {
sanitizedHandle = `@${sanitizedHandle}`
}
return sanitizedHandle
} }
} }
+2
View File
@@ -45,6 +45,7 @@ export type CommonNavigatorParams = {
Feeds: undefined Feeds: undefined
Start: {name: string; rkey: string} Start: {name: string; rkey: string}
StarterPack: {name: string; rkey: string; new?: boolean} StarterPack: {name: string; rkey: string; new?: boolean}
StarterPackShort: {code: string}
StarterPackWizard: undefined StarterPackWizard: undefined
StarterPackEdit: { StarterPackEdit: {
rkey?: string rkey?: string
@@ -102,6 +103,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
Messages: {animation?: 'push' | 'pop'} Messages: {animation?: 'push' | 'pop'}
Start: {name: string; rkey: string} Start: {name: string; rkey: string}
StarterPack: {name: string; rkey: string; new?: boolean} StarterPack: {name: string; rkey: string; new?: boolean}
StarterPackShort: {code: string}
StarterPackWizard: undefined StarterPackWizard: undefined
StarterPackEdit: { StarterPackEdit: {
rkey?: string rkey?: string

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