Compare commits

..

2 Commits

Author SHA1 Message Date
Hailey b685f02a65 add one more usage 2025-03-17 15:02:47 -07:00
Hailey 5568be4bc7 remove .parse usage 2025-03-17 15:00:11 -07:00
181 changed files with 43436 additions and 89012 deletions
+1 -2
View File
@@ -32,7 +32,6 @@ module.exports = {
'H6',
'P',
'Admonition',
'Admonition.Admonition',
],
impliedTextProps: [],
suggestedTextWrappers: {
@@ -92,9 +91,9 @@ module.exports = {
'*.lock',
'.husky',
'patches',
'bskyweb',
'*.html',
'bskyweb',
'bskyembed',
'src/locale/locales/_build/',
'src/locale/locales/**/*.js',
],
+2 -2
View File
@@ -14,7 +14,7 @@ on:
jobs:
build:
name: Build and Submit iOS
runs-on: macos-15
runs-on: macos-14
steps:
- name: Check for EXPO_TOKEN
run: >
@@ -52,7 +52,7 @@ jobs:
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '16.2'
xcode-version: '15.3'
- name: ☕️ Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1
@@ -134,7 +134,7 @@ jobs:
# GitHub actions are horrible so let's just copy paste this in
buildIfNecessaryIOS:
name: Build and Submit iOS
runs-on: macos-15
runs-on: macos-14
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}-build-ios
cancel-in-progress: true
@@ -176,7 +176,7 @@ jobs:
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '16.2'
xcode-version: '15.3'
- name: ☕️ Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1
+1 -1
View File
@@ -1,2 +1,2 @@
flows:
- "flows/**"
- "flows/*"
+1 -1
View File
@@ -17,7 +17,7 @@ appId: xyz.blueskyweb.app
- inputText: "Post with an image"
- tapOn:
id: "openGalleryBtn"
- tapOn: "Allow Full Access"
- tapOn: "Content warnings"
- tapOn: "Content warnings"
- tapOn: "Porn"
- tapOn:
+4 -2
View File
@@ -24,10 +24,12 @@ appId: xyz.blueskyweb.app
- inputText: "example@test.com"
- tapOn:
id: "passwordInput"
- inputText: "hunter22"
- hideKeyboard
- inputText: "hunter2"
- pressKey: Enter
- tapOn:
id: "nextBtn"
- tapOn:
id: "handleInput"
- inputText: "e2e-test"
- tapOn:
id: "nextBtn"
-20
View File
@@ -1,20 +0,0 @@
appId: xyz.blueskyweb.app
---
- runScript:
file: ../setupServer.js
env:
SERVER_PATH: "?users&follows&posts"
- runFlow:
file: ../setupApp.yml
- tapOn:
id: "e2eSignInAlice"
- tapOn:
id: "postDropdownBtn"
index: 0
- tapOn:
id: "postDropdownReportBtn"
- tapOn: "Create report for Misleading Post"
- tapOn: "Send report to Dev-env Moderation"
- tapOn:
point: "50%,90%"
@@ -1,24 +0,0 @@
appId: xyz.blueskyweb.app
---
- runScript:
file: ../../setupServer.js
env:
SERVER_PATH: ?users&follows&posts&feeds
- runFlow:
file: ../../setupApp.yml
- tapOn:
id: "e2eSignInAlice"
- tapOn: "Carla's avatar"
- tapOn:
id: "profileHeaderDropdownBtn"
- tapOn:
id: "profileHeaderDropdownReportBtn"
- tapOn:
id: "report:option:com.atproto.moderation.defs#reasonSpam"
- assertVisible:
id: "report:labeler:mod-authority.test"
- tapOn:
id: "report:submit"
- assertNotVisible:
id: "report:dialog"
@@ -1,24 +0,0 @@
appId: xyz.blueskyweb.app
---
- runScript:
file: ../../setupServer.js
env:
SERVER_PATH: ?users&follows&posts&feeds
- runFlow:
file: ../../setupApp.yml
- tapOn:
id: "e2eSignInAlice"
- tapOn:
id: "postDropdownBtn"
index: 0
- tapOn:
id: "postDropdownReportBtn"
- tapOn:
id: "report:option:com.atproto.moderation.defs#reasonSpam"
- assertVisible:
id: "report:labeler:mod-authority.test"
- tapOn:
id: "report:submit"
- assertNotVisible:
id: "report:dialog"
@@ -1,35 +0,0 @@
appId: xyz.blueskyweb.app
---
- runScript:
file: ../../setupServer.js
env:
SERVER_PATH: ?users&follows&posts&feeds
- runFlow:
file: ../../setupApp.yml
- tapOn:
id: "e2eSignInAlice"
- tapOn:
id: "postDropdownBtn"
index: 0
- tapOn:
id: "postDropdownReportBtn"
- tapOn:
id: "report:option:com.atproto.moderation.defs#reasonOther"
- assertVisible:
id: "report:labeler:mod-authority.test"
# reason "other" defaults with details open
- assertVisible:
id: "report:details"
- tapOn:
id: "report:clearOption"
- assertNotVisible:
id: "report:details"
- tapOn:
id: "report:option:com.atproto.moderation.defs#reasonSpam"
- assertVisible:
id: "report:labeler:mod-authority.test"
- tapOn:
id: "report:submit"
- assertNotVisible:
id: "report:dialog"
@@ -1,30 +0,0 @@
appId: xyz.blueskyweb.app
---
- runScript:
file: ../../setupServer.js
env:
SERVER_PATH: ?users&follows&posts&feeds
- runFlow:
file: ../../setupApp.yml
- tapOn:
id: "e2eSignInAlice"
- tapOn:
id: "postDropdownBtn"
index: 0
- tapOn:
id: "postDropdownReportBtn"
- tapOn:
id: "report:option:com.atproto.moderation.defs#reasonOther"
- assertVisible:
id: "report:labeler:mod-authority.test"
# reason "other" defaults with details open
- assertVisible:
id: "report:details"
- tapOn:
id: "report:details"
- inputText: "This is a test report"
- tapOn:
id: "report:submit"
- assertNotVisible:
id: "report:dialog"
-4
View File
@@ -75,17 +75,14 @@ module.exports = function (config) {
'an',
'ast',
'ca',
'cy',
'da',
'de',
'el',
'eo',
'es',
'eu',
'fi',
'fr',
'ga',
'gd',
'gl',
'hi',
'hu',
@@ -240,7 +237,6 @@ module.exports = function (config) {
},
],
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
'./plugins/withGradleJVMHeapSizeIncrease.js',
'./plugins/withAndroidManifestPlugin.js',
'./plugins/withAndroidManifestFCMIconPlugin.js',
'./plugins/withAndroidStylesAccentColorPlugin.js',
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm6.293-3.707a1 1 0 0 1 1.414 0L12 10.586l2.293-2.293a1 1 0 1 1 1.414 1.414L13.414 12l2.293 2.293a1 1 0 0 1-1.414 1.414L12 13.414l-2.293 2.293a1 1 0 0 1-1.414-1.414L10.586 12 8.293 9.707a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 454 B

-1
View File
@@ -68,7 +68,6 @@ function scan(node = document) {
if (ref_url.startsWith('http')) {
searchParams.set('ref_url', encodeURIComponent(ref_url))
}
searchParams.set('colorMode', embed.dataset.blueskyColorMode || 'system')
const iframe = document.createElement('iframe')
iframe.setAttribute('data-bluesky-id', id)
+1 -7
View File
@@ -1,15 +1,9 @@
export type ColorModeValues = 'system' | 'light' | 'dark'
export function assertColorModeValues(value: string): value is ColorModeValues {
return ['system', 'light', 'dark'].includes(value)
}
export function applyTheme(theme: 'light' | 'dark') {
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(theme)
}
export function initSystemColorMode() {
export function initColorMode() {
applyTheme(
window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
-11
View File
@@ -9,14 +9,3 @@
:root {
color-scheme: light dark;
}
select {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' height='14px' width='14px' fill='none' viewBox='0 0 24 24'><path fill='black' fill-rule='evenodd' d='M3.293 8.293a1 1 0 0 1 1.414 0L12 15.586l7.293-7.293a1 1 0 1 1 1.414 1.414l-8 8a1 1 0 0 1-1.414 0l-8-8a1 1 0 0 1 0-1.414Z' clip-rule='evenodd'/></svg>");
background-repeat: no-repeat;
background-position: calc(100% - 0.75rem) center;
padding-right: 2rem;
@media (prefers-color-scheme: dark) {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' height='14px' width='14px' fill='none' viewBox='0 0 24 24'><path fill='white' fill-rule='evenodd' d='M3.293 8.293a1 1 0 0 1 1.414 0L12 15.586l7.293-7.293a1 1 0 1 1 1.414 1.414l-8 8a1 1 0 0 1-1.414 0l-8-8a1 1 0 0 1 0-1.414Z' clip-rule='evenodd'/></svg>");
}
}
+19 -58
View File
@@ -1,16 +1,12 @@
import '../index.css'
import {AppBskyFeedDefs, AppBskyFeedPost, AtpAgent, AtUri} from '@atproto/api'
import {AppBskyFeedDefs, AppBskyFeedPost, AtUri, BskyAgent} from '@atproto/api'
import {h, render} from 'preact'
import {useEffect, useMemo, useRef, useState} from 'preact/hooks'
import arrowBottom from '../../assets/arrowBottom_stroke2_corner0_rounded.svg'
import logo from '../../assets/logo.svg'
import {
assertColorModeValues,
ColorModeValues,
initSystemColorMode,
} from '../color-mode'
import {initColorMode} from '../color-mode'
import {Container} from '../components/container'
import {Link} from '../components/link'
import {Post} from '../components/post'
@@ -26,9 +22,9 @@ export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
const root = document.getElementById('app')
if (!root) throw new Error('No root element')
initSystemColorMode()
initColorMode()
const agent = new AtpAgent({
const agent = new BskyAgent({
service: 'https://public.api.bsky.app',
})
@@ -36,7 +32,6 @@ render(<LandingPage />, root)
function LandingPage() {
const [uri, setUri] = useState('')
const [colorMode, setColorMode] = useState<ColorModeValues>('system')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(
@@ -125,50 +120,24 @@ function LandingPage() {
<h1 className="text-4xl font-bold text-center">Embed a Bluesky Post</h1>
<div className="flex flex-col w-full max-w-[600px] gap-6">
<input
type="text"
value={uri}
onInput={e => setUri(e.currentTarget.value)}
className="border rounded-lg py-3 px-4 dark:bg-dimmedBg dark:border-slate-500"
placeholder={DEFAULT_POST}
/>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium" for="colorModeSelect">
Theme
</label>
<select
value={colorMode}
onChange={e => {
const value = e.currentTarget.value
if (assertColorModeValues(value)) {
setColorMode(value)
}
}}
id="colorModeSelect"
className="appearance-none bg-white border w-full rounded-lg text-sm px-3 py-2 dark:bg-dimmedBg dark:border-slate-500">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
<input
type="text"
value={uri}
onInput={e => setUri(e.currentTarget.value)}
className="border rounded-lg py-3 w-full max-w-[600px] px-4 dark:bg-dimmedBg dark:border-slate-500"
placeholder={DEFAULT_POST}
/>
<img src={arrowBottom} className="w-6 dark:invert" />
{loading ? (
<div className={`${colorMode} w-full max-w-[600px]`}>
<div className="w-full max-w-[600px]">
<Skeleton />
</div>
) : (
<div className="w-full max-w-[600px] gap-8 flex flex-col">
{!error && thread && uri && (
<Snippet thread={thread} colorMode={colorMode} />
)}
<div className={colorMode}>
{!error && thread && <Post thread={thread} key={thread.post.uri} />}
</div>
{!error && thread && uri && <Snippet thread={thread} />}
{!error && thread && <Post thread={thread} key={thread.post.uri} />}
{error && (
<div className="w-full border border-red-500 bg-red-500/10 px-4 py-3 rounded-lg">
<p className="text-red-500 text-center">{error}</p>
@@ -199,13 +168,7 @@ function Skeleton() {
)
}
function Snippet({
thread,
colorMode,
}: {
thread: AppBskyFeedDefs.ThreadViewPost
colorMode: ColorModeValues
}) {
function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
const ref = useRef<HTMLInputElement>(null)
const [copied, setCopied] = useState(false)
@@ -241,11 +204,9 @@ function Snippet({
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
thread.post.uri,
)}" data-bluesky-cid="${escapeHtml(
thread.post.cid,
)}" data-bluesky-embed-color-mode="${escapeHtml(
colorMode,
)}"><p lang="${escapeHtml(lang)}">${escapeHtml(record.text)}${
)}" data-bluesky-cid="${escapeHtml(thread.post.cid)}"><p lang="${escapeHtml(
lang,
)}">${escapeHtml(record.text)}${
record.embed
? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>`
: ''
@@ -256,7 +217,7 @@ function Snippet({
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
niceDate(thread.post.indexedAt),
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
}, [thread, colorMode])
}, [thread])
return (
<div className="flex gap-2 w-full">
+2 -18
View File
@@ -4,7 +4,7 @@ import {AppBskyFeedDefs, AtpAgent} from '@atproto/api'
import {h, render} from 'preact'
import logo from '../../assets/logo.svg'
import {applyTheme, initSystemColorMode} from '../color-mode'
import {initColorMode} from '../color-mode'
import {Container} from '../components/container'
import {Link} from '../components/link'
import {Post} from '../components/post'
@@ -22,23 +22,7 @@ if (!uri) {
throw new Error('No uri in path')
}
const query = new URLSearchParams(window.location.search)
// theme - default to light mode
const colorMode = query.get('colorMode')
switch (colorMode) {
case 'dark':
applyTheme('dark')
break
case 'system':
initSystemColorMode()
break
case 'light':
default:
applyTheme('light')
break
}
initColorMode()
agent
.getPostThread({
-14
View File
@@ -94,20 +94,6 @@ func run(args []string) {
Value: "",
EnvVars: []string{"STATIC_CDN_HOST"},
},
&cli.BoolFlag{
Name: "bsky-canonical-instance",
Usage: "Enable if this is the canonical deployment (bsky.app)",
Value: false,
Required: false,
EnvVars: []string{"BSKY_CANONICAL_INSTANCE"},
},
&cli.BoolFlag{
Name: "robots-disallow-all",
Usage: "Crawling is allowed by default. Enable this flag to Disallow all",
Value: false,
Required: false,
EnvVars: []string{"ROBOTS_DISALLOW_ALL"},
},
},
},
}
+7 -19
View File
@@ -63,8 +63,6 @@ func serve(cctx *cli.Context) error {
corsOrigins := cctx.StringSlice("cors-allowed-origins")
staticCDNHost := cctx.String("static-cdn-host")
staticCDNHost = strings.TrimSuffix(staticCDNHost, "/")
canonicalInstance := cctx.Bool("bsky-canonical-instance")
robotsDisallowAll := cctx.Bool("robots-disallow-all")
// Echo
e := echo.New()
@@ -206,23 +204,13 @@ func serve(cctx *cli.Context) error {
return http.FS(fsys)
}())
// enable some special endpoints for the "canonical" deployment (bsky.app). not having these enabled should *not* impact regular operation
if canonicalInstance {
e.GET("/ips-v4", echo.WrapHandler(staticHandler))
e.GET("/ips-v6", echo.WrapHandler(staticHandler))
e.GET("/security.txt", func(c echo.Context) error {
return c.Redirect(http.StatusMovedPermanently, "/.well-known/security.txt")
})
e.GET("/.well-known/*", echo.WrapHandler(staticHandler))
}
// default to permissive, but Disallow all if flag set
if robotsDisallowAll {
e.File("/robots.txt", "static/robots-disallow-all.txt")
} else {
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
}
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
e.GET("/ips-v4", echo.WrapHandler(staticHandler))
e.GET("/ips-v6", echo.WrapHandler(staticHandler))
e.GET("/.well-known/*", echo.WrapHandler(staticHandler))
e.GET("/security.txt", func(c echo.Context) error {
return c.Redirect(http.StatusMovedPermanently, "/.well-known/security.txt")
})
e.GET("/iframe/youtube.html", echo.WrapHandler(staticHandler))
e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", staticHandler)), func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
-3
View File
@@ -1,3 +0,0 @@
# This is an development or self-hosted instance of the bsky web app, and crawling has been disallowed by the operator team.
User-Agent: *
Disallow: /
-3
View File
@@ -5,18 +5,15 @@ module.exports = {
'an',
'ast',
'ca',
'cy',
'da',
'de',
'el',
'en-GB',
'eo',
'es',
'eu',
'fi',
'fr',
'ga',
'gd',
'gl',
'hi',
'hu',
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.99.1",
"version": "1.99.0",
"private": true,
"engines": {
"node": ">=20"
@@ -57,7 +57,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.14.7",
"@atproto/api": "^0.14.0",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
-34
View File
@@ -1,34 +0,0 @@
const {withGradleProperties} = require('expo/config-plugins')
function setGradlePropertiesValue(config, key, value) {
return withGradleProperties(config, exportedConfig => {
const keyIdx = exportedConfig.modResults.findIndex(
item => item.type === 'property' && item.key === key,
)
if (keyIdx >= 0) {
exportedConfig.modResults.splice(keyIdx, 1, {
type: 'property',
key,
value,
})
} else {
exportedConfig.modResults.push({
type: 'property',
key,
value,
})
}
return exportedConfig
})
}
module.exports = function withGradleJVMHeapSizeIncrease(config) {
config = setGradlePropertiesValue(
config,
'org.gradle.jvmargs',
'-Xmx4096m -XX:MaxMetaspaceSize=1024m', //Set data of your choice
)
return config
}
+2 -9
View File
@@ -32,7 +32,6 @@ import {
import {RouteParams, State} from '#/lib/routes/types'
import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig'
import {bskyTitle} from '#/lib/strings/headings'
import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
@@ -69,7 +68,6 @@ import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTest
import HashtagScreen from '#/screens/Hashtag'
import {MessagesScreen} from '#/screens/Messages/ChatList'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
import {Screen as ModerationInteractionSettings} from '#/screens/ModerationInteractionSettings'
@@ -412,11 +410,6 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => MessagesSettingsScreen}
options={{title: title(msg`Chat settings`), requireAuth: true}}
/>
<Stack.Screen
name="MessagesInbox"
getComponent={() => MessagesInboxScreen}
options={{title: title(msg`Chat request inbox`), requireAuth: true}}
/>
<Stack.Screen
name="NotificationSettings"
getComponent={() => NotificationSettingsScreen}
@@ -736,7 +729,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
linking={LINKING}
theme={theme}
onStateChange={() => {
logger.metric('router:navigate', {
logEvent('lake:router:navigate', {
from: prevLoggedRouteName.current,
})
prevLoggedRouteName.current = getCurrentRouteName()
@@ -745,7 +738,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
attachRouteToLogEvents(getCurrentRouteName)
logModuleInitTime()
onReady()
logger.metric('router:navigate', {})
logEvent('lake:router:navigate', {})
}}>
{children}
</NavigationContainer>
+1 -22
View File
@@ -31,18 +31,6 @@ export const atoms = {
right: 0,
bottom: 0,
},
top_0: {
top: 0,
},
right_0: {
right: 0,
},
bottom_0: {
bottom: 0,
},
left_0: {
left: 0,
},
z_10: {
zIndex: 10,
},
@@ -70,7 +58,7 @@ export const atoms = {
}),
/*
* Width & Height
* Width
*/
w_full: {
width: '100%',
@@ -81,12 +69,6 @@ export const atoms = {
h_full_vh: web({
height: '100vh',
}),
max_w_full: {
maxWidth: '100%',
},
max_h_full: {
maxHeight: '100%',
},
/**
* Used for the outermost components on screens, to ensure that they can fill
@@ -111,9 +93,6 @@ export const atoms = {
/*
* Border radius
*/
rounded_0: {
borderRadius: 0,
},
rounded_2xs: {
borderRadius: tokens.borderRadius._2xs,
},
+10 -18
View File
@@ -2,7 +2,6 @@ import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button as BaseButton, ButtonProps} from '#/components/Button'
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
import {Eye_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/Eye'
import {Leaf_Stroke2_Corner0_Rounded as TipIcon} from '#/components/icons/Leaf'
@@ -50,29 +49,22 @@ export function Text({
return (
<BaseText
{...rest}
style={[a.flex_1, a.text_sm, a.leading_snug, a.pr_md, style]}>
style={[
a.flex_1,
a.text_sm,
a.leading_snug,
{
paddingTop: 1,
},
style,
]}>
{children}
</BaseText>
)
}
export function Button({
children,
...props
}: Omit<ButtonProps, 'size' | 'variant' | 'color'>) {
return (
<BaseButton size="tiny" variant="outline" color="secondary" {...props}>
{children}
</BaseButton>
)
}
export function Row({children}: {children: React.ReactNode}) {
return (
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
{children}
</View>
)
return <View style={[a.flex_row, a.gap_sm]}>{children}</View>
}
export function Outer({
+11 -41
View File
@@ -1,37 +1,37 @@
import {View} from 'react-native'
import {moderateProfile} from '@atproto/api'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfilesQuery} from '#/state/queries/profile'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import * as bsky from '#/types/bsky'
export function AvatarStack({
profiles,
size = 26,
numPending,
backgroundColor,
}: {
profiles: bsky.profile.AnyProfileView[]
profiles: string[]
size?: number
numPending?: number
backgroundColor?: string
}) {
const halfSize = size / 2
const {data, error} = useProfilesQuery({handles: profiles})
const t = useTheme()
const moderationOpts = useModerationOpts()
const isPending = (numPending && profiles.length === 0) || !moderationOpts
if (error) {
console.error(error)
return null
}
const isPending = !data || !moderationOpts
const items = isPending
? Array.from({length: numPending ?? profiles.length}).map((_, i) => ({
? Array.from({length: profiles.length}).map((_, i) => ({
key: i,
profile: null,
moderation: null,
}))
: profiles.map(item => ({
: data.profiles.map(item => ({
key: item.did,
profile: item,
moderation: moderateProfile(item, moderationOpts),
@@ -56,7 +56,7 @@ export function AvatarStack({
height: size,
left: i * -halfSize,
borderWidth: 1,
borderColor: backgroundColor ?? t.atoms.bg.backgroundColor,
borderColor: t.atoms.bg.backgroundColor,
borderRadius: 999,
zIndex: 3 - i,
},
@@ -74,33 +74,3 @@ export function AvatarStack({
</View>
)
}
export function AvatarStackWithFetch({
profiles,
size,
backgroundColor,
}: {
profiles: string[]
size?: number
backgroundColor?: string
}) {
const {data, error} = useProfilesQuery({handles: profiles})
if (error) {
if (error.name !== 'AbortError') {
logger.error('Error fetching profiles for AvatarStack', {
safeMessage: error,
})
}
return null
}
return (
<AvatarStack
numPending={profiles.length}
profiles={data?.profiles || []}
size={size}
backgroundColor={backgroundColor}
/>
)
}
+1 -4
View File
@@ -68,10 +68,7 @@ export type DialogOuterProps = {
testID?: string
}
type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> &
T & {
testID?: string
}
type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T
export type DialogInnerProps =
| DialogInnerPropsBase<{
label?: undefined
+1 -1
View File
@@ -269,7 +269,7 @@ function SaveButtonInner({
},
])
}
Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'})))
Toast.show(_(msg`Feeds updated!`))
} catch (err: any) {
logger.error(err, {message: `FeedCard: failed to update feeds`, pin})
Toast.show(_(msg`Failed to update feeds`), 'xmark')
+9 -22
View File
@@ -33,13 +33,11 @@ export function KnownFollowers({
moderationOpts,
onLinkPress,
minimal,
showIfEmpty,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
onLinkPress?: LinkProps['onPress']
minimal?: boolean
showIfEmpty?: boolean
}) {
const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
new Map(),
@@ -66,12 +64,11 @@ export function KnownFollowers({
moderationOpts={moderationOpts}
onLinkPress={onLinkPress}
minimal={minimal}
showIfEmpty={showIfEmpty}
/>
)
}
return <EmptyFallback show={showIfEmpty} />
return null
}
function KnownFollowersInner({
@@ -80,19 +77,22 @@ function KnownFollowersInner({
cachedKnownFollowers,
onLinkPress,
minimal,
showIfEmpty,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
onLinkPress?: LinkProps['onPress']
minimal?: boolean
showIfEmpty?: boolean
}) {
const t = useTheme()
const {_} = useLingui()
const textStyle = [a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]
const textStyle = [
a.flex_1,
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
]
const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => {
const moderation = moderateProfile(f, moderationOpts)
@@ -115,7 +115,7 @@ function KnownFollowersInner({
* We check above too, but here for clarity and a reminder to _check for
* valid indices_
*/
if (slice.length === 0) return <EmptyFallback show={showIfEmpty} />
if (slice.length === 0) return null
const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE
@@ -127,7 +127,7 @@ function KnownFollowersInner({
onPress={onLinkPress}
to={makeProfileLink(profile, 'known-followers')}
style={[
a.max_w_full,
a.flex_1,
a.flex_row,
minimal ? a.gap_sm : a.gap_md,
a.align_center,
@@ -172,7 +172,6 @@ function KnownFollowersInner({
<Text
style={[
a.flex_shrink,
textStyle,
hovered && {
textDecorationLine: 'underline',
@@ -244,15 +243,3 @@ function KnownFollowersInner({
</Link>
)
}
function EmptyFallback({show}: {show?: boolean}) {
const t = useTheme()
if (!show) return null
return (
<Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>Not followed by anyone you're following</Trans>
</Text>
)
}
+59 -125
View File
@@ -1,5 +1,5 @@
import {memo, useEffect, useMemo, useState} from 'react'
import {View} from 'react-native'
import React, {memo, useRef, useState} from 'react'
import {TextInput, View} from 'react-native'
import {AppBskyActorDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -8,19 +8,12 @@ import {EMBED_SCRIPT} from '#/lib/constants'
import {niceDate} from '#/lib/strings/time'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import * as ToggleButton from '#/components/forms/ToggleButton'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon,
ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon,
} from '#/components/icons/Chevron'
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets'
import {Text} from '#/components/Typography'
export type ColorModeValues = 'system' | 'light' | 'dark'
import {Button, ButtonIcon, ButtonText} from '../Button'
type EmbedDialogProps = {
control: Dialog.DialogControlProps
@@ -51,12 +44,11 @@ function EmbedDialogInner({
}: Omit<EmbedDialogProps, 'control'>) {
const t = useTheme()
const {_, i18n} = useLingui()
const ref = useRef<TextInput>(null)
const [copied, setCopied] = useState(false)
const [showCustomisation, setShowCustomisation] = useState(false)
const [colorMode, setColorMode] = useState<ColorModeValues>('system')
// reset copied state after 2 seconds
useEffect(() => {
React.useEffect(() => {
if (copied) {
const timeout = setTimeout(() => {
setCopied(false)
@@ -65,7 +57,7 @@ function EmbedDialogInner({
}
}, [copied])
const snippet = useMemo(() => {
const snippet = React.useMemo(() => {
function toEmbedUrl(href: string) {
return toShareUrl(href) + '?ref_src=embed'
}
@@ -83,11 +75,9 @@ function EmbedDialogInner({
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
postUri,
)}" data-bluesky-cid="${escapeHtml(
postCid,
)}" data-bluesky-embed-color-mode="${escapeHtml(
colorMode,
)}"><p lang="${escapeHtml(lang)}">${escapeHtml(record.text)}${
)}" data-bluesky-cid="${escapeHtml(postCid)}"><p lang="${escapeHtml(
lang,
)}">${escapeHtml(record.text)}${
record.embed
? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>`
: ''
@@ -98,116 +88,60 @@ function EmbedDialogInner({
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
niceDate(i18n, timestamp),
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
}, [i18n, postUri, postCid, record, timestamp, postAuthor, colorMode])
}, [i18n, postUri, postCid, record, timestamp, postAuthor])
return (
<Dialog.Inner label={_(msg`Embed post`)} style={[{maxWidth: 500}]}>
<View style={[a.gap_lg]}>
<View style={[a.gap_sm]}>
<Text style={[a.text_2xl, a.font_heavy]}>
<Trans>Embed post</Trans>
</Text>
<Text
style={[a.text_md, t.atoms.text_contrast_medium, a.leading_normal]}>
<Trans>
Embed this post in your website. Simply copy the following snippet
and paste it into the HTML code of your website.
</Trans>
</Text>
</View>
<View
style={[
a.border,
t.atoms.border_contrast_low,
a.rounded_sm,
a.overflow_hidden,
]}>
<Button
label={
showCustomisation
? _(msg`Hide customization options`)
: _(msg`Show customization options`)
}
color="secondary"
variant="ghost"
size="small"
shape="default"
onPress={() => setShowCustomisation(c => !c)}
style={[
a.justify_start,
showCustomisation && t.atoms.bg_contrast_25,
]}>
<ButtonIcon
icon={showCustomisation ? ChevronBottomIcon : ChevronRightIcon}
/>
<ButtonText>
<Trans>Customization options</Trans>
</ButtonText>
</Button>
<Dialog.Inner label="Embed post" style={[a.gap_md, {maxWidth: 500}]}>
<View style={[a.gap_sm, a.pb_lg]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Embed post</Trans>
</Text>
<Text
style={[a.text_md, t.atoms.text_contrast_medium, a.leading_normal]}>
<Trans>
Embed this post in your website. Simply copy the following snippet
and paste it into the HTML code of your website.
</Trans>
</Text>
</View>
{showCustomisation && (
<View style={[a.gap_sm, a.p_md]}>
<Text style={[t.atoms.text_contrast_medium, a.font_bold]}>
<Trans>Color theme</Trans>
</Text>
<ToggleButton.Group
label={_(msg`Color mode`)}
values={[colorMode]}
onChange={([value]) => setColorMode(value as ColorModeValues)}>
<ToggleButton.Button name="system" label={_(msg`System`)}>
<ToggleButton.ButtonText>
<Trans>System</Trans>
</ToggleButton.ButtonText>
</ToggleButton.Button>
<ToggleButton.Button name="light" label={_(msg`Light`)}>
<ToggleButton.ButtonText>
<Trans>Light</Trans>
</ToggleButton.ButtonText>
</ToggleButton.Button>
<ToggleButton.Button name="dark" label={_(msg`Dark`)}>
<ToggleButton.ButtonText>
<Trans>Dark</Trans>
</ToggleButton.ButtonText>
</ToggleButton.Button>
</ToggleButton.Group>
</View>
)}
<View style={[a.flex_row, a.gap_sm]}>
<View style={[a.flex_1]}>
<TextField.Root>
<TextField.Icon icon={CodeBrackets} />
<TextField.Input
label={_(msg`Embed HTML code`)}
editable={false}
selection={{start: 0, end: snippet.length}}
value={snippet}
style={{}}
/>
</TextField.Root>
</View>
<View style={[a.flex_row, a.gap_sm]}>
<View style={[a.flex_1]}>
<TextField.Root>
<TextField.Icon icon={CodeBracketsIcon} />
<TextField.Input
label={_(msg`Embed HTML code`)}
editable={false}
selection={{start: 0, end: snippet.length}}
value={snippet}
/>
</TextField.Root>
</View>
<Button
label={_(msg`Copy code`)}
color="primary"
variant="solid"
size="large"
onPress={() => {
navigator.clipboard.writeText(snippet)
setCopied(true)
}}>
{copied ? (
<>
<ButtonIcon icon={CheckIcon} />
<ButtonText>
<Trans>Copied!</Trans>
</ButtonText>
</>
) : (
<Button
label={_(msg`Copy code`)}
color="primary"
variant="solid"
size="large"
onPress={() => {
ref.current?.focus()
ref.current?.setSelection(0, snippet.length)
navigator.clipboard.writeText(snippet)
setCopied(true)
}}>
{copied ? (
<>
<ButtonIcon icon={Check} />
<ButtonText>
<Trans>Copy code</Trans>
<Trans>Copied!</Trans>
</ButtonText>
)}
</Button>
</View>
</>
) : (
<ButtonText>
<Trans>Copy code</Trans>
</ButtonText>
)}
</Button>
</View>
<Dialog.Close />
</Dialog.Inner>
+2 -2
View File
@@ -173,9 +173,9 @@ function MenuContent({
const {mutate: muteConvo} = useMuteConvo(convoId, {
onSuccess: data => {
if (data.convo.muted) {
Toast.show(_(msg({message: 'Chat muted', context: 'toast'})))
Toast.show(_(msg`Chat muted`))
} else {
Toast.show(_(msg({message: 'Chat unmuted', context: 'toast'})))
Toast.show(_(msg`Chat unmuted`))
}
},
onError: () => {
+1 -5
View File
@@ -13,12 +13,10 @@ export function LeaveConvoPrompt({
control,
convoId,
currentScreen,
hasMessages = true,
}: {
control: DialogOuterProps['control']
convoId: string
currentScreen: 'list' | 'conversation'
hasMessages?: boolean
}) {
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
@@ -41,9 +39,7 @@ export function LeaveConvoPrompt({
control={control}
title={_(msg`Leave conversation`)}
description={_(
hasMessages
? msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`
: msg`Are you sure you want to leave this conversation?`,
msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`,
)}
confirmButtonCta={_(msg`Leave`)}
confirmButtonColor="negative"
+1 -3
View File
@@ -69,9 +69,7 @@ export let MessageMenu = ({
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo
.deleteMessage(message.id)
.then(() =>
Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))),
)
.then(() => Toast.show(_(msg`Message deleted`)))
.catch(() => Toast.show(_(msg`Failed to delete message`)))
}, [_, convo, message.id])
+12 -33
View File
@@ -8,15 +8,13 @@ import {useNavigation} from '@react-navigation/native'
import {useEmail} from '#/lib/hooks/useEmail'
import {NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig'
import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import * as Toast from '#/view/com/util/Toast'
import {useMaybeConvoForUser} from '#/state/queries/messages/get-convo-for-members'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
import {canBeMessaged} from '#/components/dms/util'
import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message'
import {useDialogControl} from '../Dialog'
import {VerifyEmailDialog} from '../dialogs/VerifyEmailDialog'
export function MessageProfileButton({
profile,
@@ -29,19 +27,10 @@ export function MessageProfileButton({
const {needsEmailVerification} = useEmail()
const verifyEmailControl = useDialogControl()
const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did)
const {mutate: initiateConvo} = useGetConvoForMembers({
onSuccess: ({convo}) => {
logEvent('chat:open', {logContext: 'ProfileHeader'})
navigation.navigate('MessagesConversation', {conversation: convo.id})
},
onError: () => {
Toast.show(_(msg`Failed to create conversation`))
},
})
const {data: convo, isPending} = useMaybeConvoForUser(profile.did)
const onPress = React.useCallback(() => {
if (!convoAvailability?.canChat) {
if (!convo?.id) {
return
}
@@ -50,25 +39,15 @@ export function MessageProfileButton({
return
}
if (convoAvailability.convo) {
logEvent('chat:open', {logContext: 'ProfileHeader'})
navigation.navigate('MessagesConversation', {
conversation: convoAvailability.convo.id,
})
} else {
if (convo && !convo.lastMessage) {
logEvent('chat:create', {logContext: 'ProfileHeader'})
initiateConvo([profile.did])
}
}, [
needsEmailVerification,
verifyEmailControl,
navigation,
profile.did,
initiateConvo,
convoAvailability,
])
logEvent('chat:open', {logContext: 'ProfileHeader'})
if (!convoAvailability) {
navigation.navigate('MessagesConversation', {conversation: convo.id})
}, [needsEmailVerification, verifyEmailControl, convo, navigation])
if (isPending) {
// show pending state based on declaration
if (canBeMessaged(profile)) {
return (
@@ -90,7 +69,7 @@ export function MessageProfileButton({
}
}
if (convoAvailability.canChat) {
if (convo) {
return (
<>
<Button
+3 -3
View File
@@ -53,10 +53,10 @@ export let MessagesListHeader = ({
}, [moderation])
const onPressBack = useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
if (isWeb) {
navigation.replace('Messages', {})
} else {
navigation.navigate('Messages', {})
navigation.goBack()
}
}, [navigation])
+9 -16
View File
@@ -311,19 +311,6 @@ function DoneStep({
},
})
let btnText = _(msg`Done`)
let toastMsg: string | undefined
if (actions.includes('leave') && actions.includes('block')) {
btnText = _(msg`Block and Delete`)
toastMsg = _(msg({message: 'Conversation deleted', context: 'toast'}))
} else if (actions.includes('leave')) {
btnText = _(msg`Delete Conversation`)
toastMsg = _(msg({message: 'Conversation deleted', context: 'toast'}))
} else if (actions.includes('block')) {
btnText = _(msg`Block User`)
toastMsg = _(msg({message: 'User blocked', context: 'toast'}))
}
const onPressPrimaryAction = () => {
control.close(() => {
if (actions.includes('block')) {
@@ -332,12 +319,18 @@ function DoneStep({
if (actions.includes('leave')) {
leaveConvo()
}
if (toastMsg) {
Toast.show(toastMsg, 'check')
}
})
}
let btnText = _(msg`Done`)
if (actions.includes('leave') && actions.includes('block')) {
btnText = _(msg`Block and Delete`)
} else if (actions.includes('leave')) {
btnText = _(msg`Delete Conversation`)
} else if (actions.includes('block')) {
btnText = _(msg`Block User`)
}
return (
<View style={a.gap_2xl}>
<View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
@@ -77,10 +77,7 @@ export function SearchablePeopleList({
isFetching,
} = useActorAutocompleteQuery(searchText, true, 12)
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
const {data: convos} = useListConvosQuery({
enabled: showRecentConvos,
status: 'accepted',
})
const {data: convos} = useListConvosQuery({enabled: showRecentConvos})
const items = useMemo(() => {
let _items: Item[] = []
+17 -19
View File
@@ -41,27 +41,25 @@ export function HostingProvider({
onSelect={onSelectServiceUrl}
/>
{minimal ? (
<View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_xs]}>
<View style={[a.flex_row, a.align_center, a.flex_wrap]}>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Trans>You are creating an account on</Trans>
<Trans>
You are creating an account on{' '}
<Button
label={toNiceDomain(serviceUrl)}
accessibilityHint={_(msg`Changes hosting provider`)}
onPress={onPressSelectService}
variant="ghost"
color="secondary"
size="tiny"
style={[a.px_xs, {marginHorizontal: tokens.space.xs * -1}]}>
<ButtonText style={[a.text_sm]}>
{toNiceDomain(serviceUrl)}
</ButtonText>
<ButtonIcon icon={PencilIcon} />
</Button>
</Trans>
</Text>
<Button
label={toNiceDomain(serviceUrl)}
accessibilityHint={_(msg`Changes hosting provider`)}
onPress={onPressSelectService}
variant="ghost"
color="secondary"
size="tiny"
style={[
a.px_xs,
{marginHorizontal: tokens.space.xs * -1},
{paddingVertical: 0},
]}>
<ButtonText style={[a.text_sm]}>
{toNiceDomain(serviceUrl)}
</ButtonText>
<ButtonIcon icon={PencilIcon} />
</Button>
</View>
) : (
<Button
-6
View File
@@ -11,17 +11,14 @@ import React from 'react'
import {formatDistance, Locale} from 'date-fns'
import {
ca,
cy,
da,
de,
el,
enGB,
eo,
es,
eu,
fi,
fr,
gd,
gl,
hi,
hu,
@@ -56,18 +53,15 @@ const locales: Record<AppLanguage, Locale | undefined> = {
an: undefined,
ast: undefined,
ca,
cy,
da,
de,
el,
['en-GB']: enGB,
eo,
es,
eu,
fi,
fr,
ga: undefined,
gd,
gl,
hi,
hu,
-5
View File
@@ -1,5 +0,0 @@
import {createSinglePathSVG} from './TEMPLATE'
export const CircleX_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm6.293-3.707a1 1 0 0 1 1.414 0L12 10.586l2.293-2.293a1 1 0 1 1 1.414 1.414L13.414 12l2.293 2.293a1 1 0 0 1-1.414 1.414L12 13.414l-2.293 2.293a1 1 0 0 1-1.414-1.414L10.586 12 8.293 9.707a1 1 0 0 1 0-1.414Z',
})
@@ -255,7 +255,7 @@ function AppealForm({
},
onSuccess: () => {
control.close()
Toast.show(_(msg({message: 'Appeal submitted', context: 'toast'})))
Toast.show(_(msg`Appeal submitted`))
},
})
@@ -1,99 +0,0 @@
import {
$Typed,
ChatBskyConvoDefs,
ComAtprotoModerationCreateReport,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {ReportState} from './state'
import {ParsedReportSubject} from './types'
export function useSubmitReportMutation() {
const {_} = useLingui()
const agent = useAgent()
return useMutation({
async mutationFn({
subject,
state,
}: {
subject: ParsedReportSubject
state: ReportState
}) {
if (!state.selectedOption) {
throw new Error(_(msg`Please select a reason for this report`))
}
if (!state.selectedLabeler) {
throw new Error(_(msg`Please select a moderation service`))
}
let report:
| ComAtprotoModerationCreateReport.InputSchema
| (Omit<ComAtprotoModerationCreateReport.InputSchema, 'subject'> & {
subject: $Typed<ChatBskyConvoDefs.MessageRef>
})
switch (subject.type) {
case 'account': {
report = {
reasonType: state.selectedOption.reason,
reason: state.details,
subject: {
$type: 'com.atproto.admin.defs#repoRef',
did: subject.did,
},
}
break
}
case 'post':
case 'list':
case 'feed':
case 'starterPack': {
report = {
reasonType: state.selectedOption.reason,
reason: state.details,
subject: {
$type: 'com.atproto.repo.strongRef',
uri: subject.uri,
cid: subject.cid,
},
}
break
}
case 'chatMessage': {
report = {
reasonType: state.selectedOption.reason,
reason: state.details,
subject: {
$type: 'chat.bsky.convo.defs#messageRef',
messageId: subject.message.id,
convoId: subject.convoId,
did: subject.message.sender.did,
},
}
break
}
}
if (__DEV__) {
logger.info('Submitting report', {
labeler: {
handle: state.selectedLabeler.creator.handle,
},
report,
})
} else {
await agent.createModerationReport(report, {
encoding: 'application/json',
headers: {
'atproto-proxy': `${state.selectedLabeler.creator.did}#atproto_labeler`,
},
})
}
},
})
}
@@ -1 +0,0 @@
export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
@@ -1,49 +0,0 @@
import {useMemo} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ParsedReportSubject} from './types'
export function useCopyForSubject(subject: ParsedReportSubject) {
const {_} = useLingui()
return useMemo(() => {
switch (subject.type) {
case 'account': {
return {
title: _(msg`Report this user`),
subtitle: _(msg`Why should this user be reviewed?`),
}
}
case 'post': {
return {
title: _(msg`Report this post`),
subtitle: _(msg`Why should this post be reviewed?`),
}
}
case 'list': {
return {
title: _(msg`Report this list`),
subtitle: _(msg`Why should this list be reviewed?`),
}
}
case 'feed': {
return {
title: _(msg`Report this feed`),
subtitle: _(msg`Why should this feed be reviewed?`),
}
}
case 'starterPack': {
return {
title: _(msg`Report this starter pack`),
subtitle: _(msg`Why should this starter pack be reviewed?`),
}
}
case 'chatMessage': {
return {
title: _(msg`Report this message`),
subtitle: _(msg`Why should this message be reviewed?`),
}
}
}
}, [_, subject])
}
@@ -1,679 +0,0 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {AppBskyLabelerDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {wait} from '#/lib/async/wait'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {sanitizeHandle} from '#/lib/strings/handles'
import {Logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useMyLabelersQuery} from '#/state/queries/preferences'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useGutters, useTheme} from '#/alf'
import * as Admonition from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise'
import {
Check_Stroke2_Corner0_Rounded as CheckThin,
CheckThick_Stroke2_Corner0_Rounded as Check,
} from '#/components/icons/Check'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {createStaticClick, InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {useSubmitReportMutation} from './action'
import {DMCA_LINK} from './const'
import {useCopyForSubject} from './copy'
import {initialState, reducer} from './state'
import {ReportDialogProps, ReportSubject} from './types'
import {parseReportSubject} from './utils/parseReportSubject'
import {ReportOption, useReportOptions} from './utils/useReportOptions'
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
const logger = Logger.create(Logger.Context.ReportDialog)
export function ReportDialog(
props: Omit<ReportDialogProps, 'subject'> & {
subject: ReportSubject
},
) {
const subject = React.useMemo(
() => parseReportSubject(props.subject),
[props.subject],
)
const onClose = React.useCallback(() => {
logger.metric('reportDialog:close', {})
}, [])
return (
<Dialog.Outer control={props.control} onClose={onClose}>
<Dialog.Handle />
{subject ? <Inner {...props} subject={subject} /> : <Invalid />}
</Dialog.Outer>
)
}
/**
* This should only be shown if the dialog is configured incorrectly by a
* developer, but nevertheless we should have a graceful fallback.
*/
function Invalid() {
const {_} = useLingui()
return (
<Dialog.ScrollableInner label={_(msg`Report dialog`)}>
<Text style={[a.font_heavy, a.text_xl, a.leading_snug, a.pb_xs]}>
<Trans>Invalid report subject</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
Something wasn't quite right with the data you're trying to report.
Please contact support.
</Trans>
</Text>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function Inner(props: ReportDialogProps) {
const t = useTheme()
const {_} = useLingui()
const ref = React.useRef<ScrollView>(null)
const {
data: allLabelers,
isLoading: isLabelerLoading,
error: labelersLoadError,
refetch: refetchLabelers,
} = useMyLabelersQuery({excludeNonConfigurableLabelers: true})
const isLoading = useDelayedLoading(500, isLabelerLoading)
const copy = useCopyForSubject(props.subject)
const reportOptions = useReportOptions()
const [state, dispatch] = React.useReducer(reducer, initialState)
/**
* Submission handling
*/
const {mutateAsync: submitReport} = useSubmitReportMutation()
const [isPending, setPending] = React.useState(false)
const [isSuccess, setSuccess] = React.useState(false)
/**
* Labelers that support this `subject` and its NSID collection
*/
const supportedLabelers = React.useMemo(() => {
if (!allLabelers) return []
return allLabelers
.filter(l => {
const subjectTypes: string[] | undefined = l.subjectTypes
if (subjectTypes === undefined) return true
if (props.subject.type === 'account') {
return subjectTypes.includes('account')
} else if (props.subject.type === 'chatMessage') {
return subjectTypes.includes('chat')
} else {
return subjectTypes.includes('record')
}
})
.filter(l => {
const collections: string[] | undefined = l.subjectCollections
if (collections === undefined) return true
// all chat collections accepted, since only Bluesky handles chats
if (props.subject.type === 'chatMessage') return true
return collections.includes(props.subject.nsid)
})
.filter(l => {
if (!state.selectedOption) return true
const reasonTypes: string[] | undefined = l.reasonTypes
if (reasonTypes === undefined) return true
return reasonTypes.includes(state.selectedOption.reason)
})
}, [props, allLabelers, state.selectedOption])
const hasSupportedLabelers = !!supportedLabelers.length
const hasSingleSupportedLabeler = supportedLabelers.length === 1
const onSubmit = React.useCallback(async () => {
dispatch({type: 'clearError'})
logger.info('submitting')
try {
setPending(true)
// wait at least 1s, make it feel substantial
await wait(
1e3,
submitReport({
subject: props.subject,
state,
}),
)
setSuccess(true)
logger.metric('reportDialog:success', {
reason: state.selectedOption?.reason!,
labeler: state.selectedLabeler?.creator.handle!,
details: !!state.details,
})
// give time for user feedback
setTimeout(() => {
props.control.close()
}, 1e3)
} catch (e: any) {
logger.metric('reportDialog:failure', {})
logger.error(e, {
source: 'ReportDialog',
})
dispatch({
type: 'setError',
error: _(msg`Something went wrong. Please try again.`),
})
} finally {
setPending(false)
}
}, [_, submitReport, state, dispatch, props, setPending, setSuccess])
React.useEffect(() => {
logger.metric('reportDialog:open', {
subjectType: props.subject.type,
})
}, [props.subject])
return (
<Dialog.ScrollableInner
testID="report:dialog"
label={_(msg`Report dialog`)}
ref={ref}
style={[a.w_full, {maxWidth: 500}]}>
<View style={[a.gap_2xl, isNative && a.pt_md]}>
<StepOuter>
<StepTitle
index={1}
title={copy.subtitle}
activeIndex1={state.activeStepIndex1}
/>
{isLoading ? (
<View style={[a.gap_sm]}>
<OptionCardSkeleton />
<OptionCardSkeleton />
<OptionCardSkeleton />
<OptionCardSkeleton />
<OptionCardSkeleton />
{/* Here to capture focus for a hot sec to prevent flash */}
<Pressable accessible={false} />
</View>
) : labelersLoadError || !allLabelers ? (
<Admonition.Outer type="error">
<Admonition.Row>
<Admonition.Icon />
<Admonition.Text>
<Trans>Something went wrong, please try again</Trans>
</Admonition.Text>
<Admonition.Button
label={_(msg`Retry loading report options`)}
onPress={() => refetchLabelers()}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
<ButtonIcon icon={Retry} />
</Admonition.Button>
</Admonition.Row>
</Admonition.Outer>
) : (
<>
{state.selectedOption ? (
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<View style={[a.flex_1]}>
<OptionCard option={state.selectedOption} />
</View>
<Button
testID="report:clearOption"
label={_(msg`Change report reason`)}
size="tiny"
variant="solid"
color="secondary"
shape="round"
onPress={() => {
dispatch({type: 'clearOption'})
}}>
<ButtonIcon icon={X} />
</Button>
</View>
) : (
<View style={[a.gap_sm]}>
{reportOptions[props.subject.type].map(o => (
<OptionCard
key={o.reason}
option={o}
onSelect={() => {
dispatch({type: 'selectOption', option: o})
}}
/>
))}
{['post', 'account'].includes(props.subject.type) && (
<Link
to={DMCA_LINK}
label={_(
msg`View details for reporting a copyright violation`,
)}>
{({hovered, pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
a.w_full,
a.px_md,
a.py_sm,
a.rounded_sm,
a.border,
hovered || pressed
? [t.atoms.border_contrast_high]
: [t.atoms.border_contrast_low],
]}>
<Text style={[a.flex_1, a.italic, a.leading_snug]}>
<Trans>Need to report a copyright violation?</Trans>
</Text>
<SquareArrowTopRight
size="sm"
fill={t.atoms.text.color}
/>
</View>
)}
</Link>
)}
</View>
)}
</>
)}
</StepOuter>
<StepOuter>
<StepTitle
index={2}
title={_(msg`Select moderation service`)}
activeIndex1={state.activeStepIndex1}
/>
{state.activeStepIndex1 >= 2 && (
<>
{state.selectedLabeler ? (
<>
{hasSingleSupportedLabeler ? (
<LabelerCard labeler={state.selectedLabeler} />
) : (
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<View style={[a.flex_1]}>
<LabelerCard labeler={state.selectedLabeler} />
</View>
<Button
label={_(msg`Change moderation service`)}
size="tiny"
variant="solid"
color="secondary"
shape="round"
onPress={() => {
dispatch({type: 'clearLabeler'})
}}>
<ButtonIcon icon={X} />
</Button>
</View>
)}
</>
) : (
<>
{hasSupportedLabelers ? (
<View style={[a.gap_sm]}>
{hasSingleSupportedLabeler ? (
<>
<LabelerCard labeler={supportedLabelers[0]} />
<ActionOnce
check={() => !state.selectedLabeler}
callback={() => {
dispatch({
type: 'selectLabeler',
labeler: supportedLabelers[0],
})
}}
/>
</>
) : (
<>
{supportedLabelers.map(l => (
<LabelerCard
key={l.creator.did}
labeler={l}
onSelect={() => {
dispatch({type: 'selectLabeler', labeler: l})
}}
/>
))}
</>
)}
</View>
) : (
// should never happen in our app
<Admonition.Admonition type="warning">
<Trans>
Unfortunately, none of your subscribed labelers supports
this report type.
</Trans>
</Admonition.Admonition>
)}
</>
)}
</>
)}
</StepOuter>
<StepOuter>
<StepTitle
index={3}
title={_(msg`Submit report`)}
activeIndex1={state.activeStepIndex1}
/>
{state.activeStepIndex1 === 3 && (
<>
<View style={[a.pb_xs, a.gap_xs]}>
<Text style={[a.leading_snug, a.pb_xs]}>
<Trans>
Your report will be sent to{' '}
<Text style={[a.font_bold, a.leading_snug]}>
{state.selectedLabeler?.creator.displayName}
</Text>
.
</Trans>{' '}
{!state.detailsOpen ? (
<InlineLinkText
label={_(msg`Add more details (optional)`)}
{...createStaticClick(() => {
dispatch({type: 'showDetails'})
})}>
<Trans>Add more details (optional)</Trans>
</InlineLinkText>
) : null}
</Text>
{state.detailsOpen && (
<View>
<Dialog.Input
testID="report:details"
multiline
value={state.details}
onChangeText={details => {
dispatch({type: 'setDetails', details})
}}
label={_(msg`Additional details (limit 300 characters)`)}
style={{paddingRight: 60}}
numberOfLines={4}
/>
<View
style={[
a.absolute,
a.flex_row,
a.align_center,
a.pr_md,
a.pb_sm,
{
bottom: 0,
right: 0,
},
]}>
<CharProgress count={state.details?.length || 0} />
</View>
</View>
)}
</View>
<Button
testID="report:submit"
label={_(msg`Submit report`)}
size="large"
variant="solid"
color="primary"
disabled={isPending || isSuccess}
onPress={onSubmit}>
<ButtonText>
<Trans>Submit report</Trans>
</ButtonText>
<ButtonIcon
icon={isSuccess ? CheckThin : isPending ? Loader : PaperPlane}
/>
</Button>
{state.error && (
<Admonition.Admonition type="error">
{state.error}
</Admonition.Admonition>
)}
</>
)}
</StepOuter>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function ActionOnce({
check,
callback,
}: {
check: () => boolean
callback: () => void
}) {
React.useEffect(() => {
if (check()) {
callback()
}
}, [check, callback])
return null
}
function StepOuter({children}: {children: React.ReactNode}) {
return <View style={[a.gap_md, a.w_full]}>{children}</View>
}
function StepTitle({
index,
title,
activeIndex1,
}: {
index: number
title: string
activeIndex1: number
}) {
const t = useTheme()
const active = activeIndex1 === index
const completed = activeIndex1 > index
return (
<View style={[a.flex_row, a.gap_sm, a.pr_3xl]}>
<View
style={[
a.justify_center,
a.align_center,
a.rounded_full,
a.border,
{
width: 24,
height: 24,
backgroundColor: active
? t.palette.primary_500
: completed
? t.palette.primary_100
: t.atoms.bg_contrast_25.backgroundColor,
borderColor: active
? t.palette.primary_500
: completed
? t.palette.primary_400
: t.atoms.border_contrast_low.borderColor,
},
]}>
{completed ? (
<Check width={12} />
) : (
<Text
style={[
a.font_heavy,
a.text_center,
t.atoms.text,
{
color: active
? 'white'
: completed
? t.palette.primary_700
: t.atoms.text_contrast_medium.color,
fontVariant: ['tabular-nums'],
width: 24,
height: 24,
lineHeight: 24,
},
]}>
{index}
</Text>
)}
</View>
<Text
style={[
a.flex_1,
a.font_heavy,
a.text_lg,
a.leading_snug,
active ? t.atoms.text : t.atoms.text_contrast_medium,
{
top: 1,
},
]}>
{title}
</Text>
</View>
)
}
function OptionCard({
option,
onSelect,
}: {
option: ReportOption
onSelect?: (option: ReportOption) => void
}) {
const t = useTheme()
const {_} = useLingui()
const gutters = useGutters(['compact'])
const onPress = React.useCallback(() => {
onSelect?.(option)
}, [onSelect, option])
return (
<Button
testID={`report:option:${option.reason}`}
label={_(msg`Create report for ${option.title}`)}
onPress={onPress}
disabled={!onSelect}>
{({hovered, pressed}) => (
<View
style={[
a.w_full,
gutters,
a.py_sm,
a.rounded_sm,
a.border,
t.atoms.bg_contrast_25,
hovered || pressed
? [t.atoms.border_contrast_high]
: [t.atoms.border_contrast_low],
]}>
<Text style={[a.text_md, a.font_bold, a.leading_snug]}>
{option.title}
</Text>
<Text
style={[a.text_sm, , a.leading_snug, t.atoms.text_contrast_medium]}>
{option.description}
</Text>
</View>
)}
</Button>
)
}
function OptionCardSkeleton() {
const t = useTheme()
return (
<View
style={[
a.w_full,
a.rounded_sm,
a.border,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
{height: 55}, // magic, based on web
]}
/>
)
}
function LabelerCard({
labeler,
onSelect,
}: {
labeler: AppBskyLabelerDefs.LabelerViewDetailed
onSelect?: (option: AppBskyLabelerDefs.LabelerViewDetailed) => void
}) {
const t = useTheme()
const {_} = useLingui()
const onPress = React.useCallback(() => {
onSelect?.(labeler)
}, [onSelect, labeler])
const title = getLabelingServiceTitle({
displayName: labeler.creator.displayName,
handle: labeler.creator.handle,
})
return (
<Button
testID={`report:labeler:${labeler.creator.handle}`}
label={_(msg`Send report to ${title}`)}
onPress={onPress}
disabled={!onSelect}>
{({hovered, pressed}) => (
<View
style={[
a.w_full,
a.p_sm,
a.flex_row,
a.align_center,
a.gap_sm,
a.rounded_md,
a.border,
t.atoms.bg_contrast_25,
hovered || pressed
? [t.atoms.border_contrast_high]
: [t.atoms.border_contrast_low],
]}>
<UserAvatar
type="labeler"
size={36}
avatar={labeler.creator.avatar}
/>
<View style={[a.flex_1]}>
<Text style={[a.text_md, a.font_bold, a.leading_snug]}>
{title}
</Text>
<Text
style={[
a.text_sm,
,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>By {sanitizeHandle(labeler.creator.handle, '@')}</Trans>
</Text>
</View>
</View>
)}
</Button>
)
}
@@ -1,109 +0,0 @@
import {AppBskyLabelerDefs, ComAtprotoModerationDefs} from '@atproto/api'
import {ReportOption} from './utils/useReportOptions'
export type ReportState = {
selectedOption?: ReportOption
selectedLabeler?: AppBskyLabelerDefs.LabelerViewDetailed
details?: string
detailsOpen: boolean
activeStepIndex1: number
error?: string
}
export type ReportAction =
| {
type: 'selectOption'
option: ReportOption
}
| {
type: 'clearOption'
}
| {
type: 'selectLabeler'
labeler: AppBskyLabelerDefs.LabelerViewDetailed
}
| {
type: 'clearLabeler'
}
| {
type: 'setDetails'
details: string
}
| {
type: 'setError'
error: string
}
| {
type: 'clearError'
}
| {
type: 'showDetails'
}
export const initialState: ReportState = {
selectedOption: undefined,
selectedLabeler: undefined,
details: undefined,
detailsOpen: false,
activeStepIndex1: 1,
}
export function reducer(state: ReportState, action: ReportAction): ReportState {
switch (action.type) {
case 'selectOption':
return {
...state,
selectedOption: action.option,
activeStepIndex1: 2,
detailsOpen:
!!state.details ||
action.option.reason === ComAtprotoModerationDefs.REASONOTHER,
}
case 'clearOption':
return {
...state,
selectedOption: undefined,
selectedLabeler: undefined,
activeStepIndex1: 1,
detailsOpen:
!!state.details ||
state.selectedOption?.reason === ComAtprotoModerationDefs.REASONOTHER,
}
case 'selectLabeler':
return {
...state,
selectedLabeler: action.labeler,
activeStepIndex1: 3,
}
case 'clearLabeler':
return {
...state,
selectedLabeler: undefined,
activeStepIndex1: 2,
detailsOpen:
!!state.details ||
state.selectedOption?.reason === ComAtprotoModerationDefs.REASONOTHER,
}
case 'setDetails':
return {
...state,
details: action.details,
}
case 'setError':
return {
...state,
error: action.error,
}
case 'clearError':
return {
...state,
error: undefined,
}
case 'showDetails':
return {
...state,
detailsOpen: true,
}
}
}
@@ -1,67 +0,0 @@
import {
$Typed,
AppBskyActorDefs,
AppBskyFeedDefs,
AppBskyGraphDefs,
ChatBskyConvoDefs,
} from '@atproto/api'
import * as Dialog from '#/components/Dialog'
export type ReportSubject =
| $Typed<AppBskyActorDefs.ProfileViewBasic>
| $Typed<AppBskyActorDefs.ProfileView>
| $Typed<AppBskyActorDefs.ProfileViewDetailed>
| $Typed<AppBskyGraphDefs.ListView>
| $Typed<AppBskyFeedDefs.GeneratorView>
| $Typed<AppBskyGraphDefs.StarterPackView>
| $Typed<AppBskyFeedDefs.PostView>
| {convoId: string; message: ChatBskyConvoDefs.MessageView}
export type ParsedReportSubject =
| {
type: 'post'
uri: string
cid: string
nsid: string
attributes: {
reply: boolean
image: boolean
video: boolean
link: boolean
quote: boolean
}
}
| {
type: 'list'
uri: string
cid: string
nsid: string
}
| {
type: 'feed'
uri: string
cid: string
nsid: string
}
| {
type: 'starterPack'
uri: string
cid: string
nsid: string
}
| {
type: 'account'
did: string
nsid: string
}
| {
type: 'chatMessage'
convoId: string
message: ChatBskyConvoDefs.MessageView
}
export type ReportDialogProps = {
control: Dialog.DialogOuterProps['control']
subject: ParsedReportSubject
}
@@ -1,91 +0,0 @@
import {
AppBskyActorDefs,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyGraphDefs,
} from '@atproto/api'
import {
ParsedReportSubject,
ReportSubject,
} from '#/components/moderation/ReportDialog/types'
import * as bsky from '#/types/bsky'
export function parseReportSubject(
subject: ReportSubject,
): ParsedReportSubject | undefined {
if (!subject) return
if ('convoId' in subject) {
return {
type: 'chatMessage',
...subject,
}
}
if (
AppBskyActorDefs.isProfileViewBasic(subject) ||
AppBskyActorDefs.isProfileView(subject) ||
AppBskyActorDefs.isProfileViewDetailed(subject)
) {
return {
type: 'account',
did: subject.did,
nsid: 'app.bsky.actor.profile',
}
} else if (AppBskyGraphDefs.isListView(subject)) {
return {
type: 'list',
uri: subject.uri,
cid: subject.cid,
nsid: 'app.bsky.graph.list',
}
} else if (AppBskyFeedDefs.isGeneratorView(subject)) {
return {
type: 'feed',
uri: subject.uri,
cid: subject.cid,
nsid: 'app.bsky.feed.generator',
}
} else if (AppBskyGraphDefs.isStarterPackView(subject)) {
return {
type: 'starterPack',
uri: subject.uri,
cid: subject.cid,
nsid: 'app.bsky.graph.starterPack',
}
} else if (AppBskyFeedDefs.isPostView(subject)) {
const record = subject.record
const embed = bsky.post.parseEmbed(subject.embed)
if (
bsky.dangerousIsType<AppBskyFeedPost.Record>(
record,
AppBskyFeedPost.isRecord,
)
) {
return {
type: 'post',
uri: subject.uri,
cid: subject.cid,
nsid: 'app.bsky.feed.post',
attributes: {
reply: !!record.reply,
image:
embed.type === 'images' ||
(embed.type === 'post_with_media' && embed.media.type === 'images'),
video:
embed.type === 'video' ||
(embed.type === 'post_with_media' && embed.media.type === 'video'),
link:
embed.type === 'link' ||
(embed.type === 'post_with_media' && embed.media.type === 'link'),
quote:
embed.type === 'post' ||
(embed.type === 'post_with_media' &&
(embed.view.type === 'post' ||
embed.view.type === 'post_with_media')),
},
}
}
}
}
@@ -1,121 +0,0 @@
import {useMemo} from 'react'
import {ComAtprotoModerationDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export interface ReportOption {
reason: string
title: string
description: string
}
interface ReportOptions {
account: ReportOption[]
post: ReportOption[]
list: ReportOption[]
starterPack: ReportOption[]
feed: ReportOption[]
chatMessage: ReportOption[]
}
export function useReportOptions(): ReportOptions {
const {_} = useLingui()
return useMemo(() => {
const other = {
reason: ComAtprotoModerationDefs.REASONOTHER,
title: _(msg`Other`),
description: _(msg`An issue not included in these options`),
}
const common = [
{
reason: ComAtprotoModerationDefs.REASONRUDE,
title: _(msg`Anti-Social Behavior`),
description: _(msg`Harassment, trolling, or intolerance`),
},
{
reason: ComAtprotoModerationDefs.REASONVIOLATION,
title: _(msg`Illegal and Urgent`),
description: _(msg`Glaring violations of law or terms of service`),
},
other,
]
return {
account: [
{
reason: ComAtprotoModerationDefs.REASONMISLEADING,
title: _(msg`Misleading Account`),
description: _(
msg`Impersonation or false claims about identity or affiliation`,
),
},
{
reason: ComAtprotoModerationDefs.REASONSPAM,
title: _(msg`Frequently Posts Unwanted Content`),
description: _(msg`Spam; excessive mentions or replies`),
},
{
reason: ComAtprotoModerationDefs.REASONVIOLATION,
title: _(msg`Name or Description Violates Community Standards`),
description: _(msg`Terms used violate community standards`),
},
other,
],
post: [
{
reason: ComAtprotoModerationDefs.REASONMISLEADING,
title: _(msg`Misleading Post`),
description: _(msg`Impersonation, misinformation, or false claims`),
},
{
reason: ComAtprotoModerationDefs.REASONSPAM,
title: _(msg`Spam`),
description: _(msg`Excessive mentions or replies`),
},
{
reason: ComAtprotoModerationDefs.REASONSEXUAL,
title: _(msg`Unwanted Sexual Content`),
description: _(msg`Nudity or adult content not labeled as such`),
},
...common,
],
chatMessage: [
{
reason: ComAtprotoModerationDefs.REASONSPAM,
title: _(msg`Spam`),
description: _(msg`Excessive or unwanted messages`),
},
{
reason: ComAtprotoModerationDefs.REASONSEXUAL,
title: _(msg`Unwanted Sexual Content`),
description: _(msg`Inappropriate messages or explicit links`),
},
...common,
],
list: [
{
reason: ComAtprotoModerationDefs.REASONVIOLATION,
title: _(msg`Name or Description Violates Community Standards`),
description: _(msg`Terms used violate community standards`),
},
...common,
],
starterPack: [
{
reason: ComAtprotoModerationDefs.REASONVIOLATION,
title: _(msg`Name or Description Violates Community Standards`),
description: _(msg`Terms used violate community standards`),
},
...common,
],
feed: [
{
reason: ComAtprotoModerationDefs.REASONVIOLATION,
title: _(msg`Name or Description Violates Community Standards`),
description: _(msg`Terms used violate community standards`),
},
...common,
],
}
}, [_])
}
-5
View File
@@ -1,5 +0,0 @@
export async function wait<T>(delay: number, fn: T): Promise<Awaited<T>> {
return await Promise.all([fn, new Promise(y => setTimeout(y, delay))]).then(
arr => arr[0],
)
}
-3
View File
@@ -168,9 +168,6 @@ export const MAX_LABELERS = 20
export const VIDEO_SERVICE = 'https://video.bsky.app'
export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app'
export const VIDEO_MAX_DURATION_MS = 3 * 60 * 1000 // 3 minutes in milliseconds
export const VIDEO_MAX_SIZE = 1000 * 1000 * 100 // 100mb
export const SUPPORTED_MIME_TYPES = [
'video/mp4',
'video/mpeg',
+3 -2
View File
@@ -1,9 +1,10 @@
import {ImagePickerAsset} from 'expo-image-picker'
import {VIDEO_MAX_SIZE} from '#/lib/constants'
import {VideoTooLargeError} from '#/lib/media/video/errors'
import {CompressedVideo} from './types'
const MAX_VIDEO_SIZE = 1000 * 1000 * 50 // 50mb
// doesn't actually compress, converts to ArrayBuffer
export async function compressVideo(
asset: ImagePickerAsset,
@@ -16,7 +17,7 @@ export async function compressVideo(
const blob = base64ToBlob(base64, mimeType)
const uri = URL.createObjectURL(blob)
if (blob.size > VIDEO_MAX_SIZE) {
if (blob.size > MAX_VIDEO_SIZE) {
throw new VideoTooLargeError()
}
+1 -2
View File
@@ -56,9 +56,8 @@ export type CommonNavigatorParams = {
Search: {q?: string}
Hashtag: {tag: string; author?: string}
Topic: {topic: string}
MessagesConversation: {conversation: string; embed?: string; accept?: true}
MessagesConversation: {conversation: string; embed?: string}
MessagesSettings: undefined
MessagesInbox: undefined
NotificationSettings: undefined
Feeds: undefined
Start: {name: string; rkey: string}
@@ -1,4 +1,4 @@
export type MetricEvents = {
export type LogEvents = {
// App events
init: {
initMs: number
@@ -30,7 +30,7 @@ export type MetricEvents = {
secondsActive: number
}
'state:foreground': {}
'router:navigate': {
'lake:router:navigate': {
from?: string
}
'deepLink:referrerReceived': {
@@ -305,28 +305,4 @@ export type MetricEvents = {
'progressGuide:hide': {}
'progressGuide:followDialog:open': {}
'moderation:subscribedToLabeler': {}
'moderation:unsubscribedFromLabeler': {}
'moderation:changeLabelPreference': {
preference: string
}
'moderation:subscribedToList': {
listType: 'mute' | 'block'
}
'moderation:unsubscribedFromList': {
listType: 'mute' | 'block'
}
'reportDialog:open': {
subjectType: string
}
'reportDialog:close': {}
'reportDialog:success': {
reason: string
labeler: string
details: boolean
}
'reportDialog:failure': {}
}
+8 -27
View File
@@ -5,12 +5,12 @@ import {Statsig, StatsigProvider} from 'statsig-react-native-expo'
import {BUNDLE_DATE, BUNDLE_IDENTIFIER, IS_TESTFLIGHT} from '#/lib/app-info'
import {logger} from '#/logger'
import {MetricEvents} from '#/logger/metrics'
import {isWeb} from '#/platform/detection'
import * as persisted from '#/state/persisted'
import {useSession} from '../../state/session'
import {timeout} from '../async/timeout'
import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback'
import {LogEvents} from './events'
import {Gate} from './gates'
const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV'
@@ -42,7 +42,7 @@ if (isWeb && typeof window !== 'undefined') {
refUrl = decodeURIComponent(params.get('ref_url') ?? '')
}
export type {MetricEvents as LogEvents}
export type {LogEvents}
function createStatsigOptions(prefetchUsers: StatsigUser[]) {
return {
@@ -91,44 +91,25 @@ export function toClout(n: number | null | undefined): number | undefined {
}
}
/**
* @deprecated use `logger.metric()` instead
*/
export function logEvent<E extends keyof MetricEvents>(
export function logEvent<E extends keyof LogEvents>(
eventName: E & string,
rawMetadata: MetricEvents[E] & FlatJSONRecord,
options: {
/**
* Send to our data lake only, not to StatSig
*/
lake?: boolean
} = {lake: false},
rawMetadata: LogEvents[E] & FlatJSONRecord,
) {
try {
const fullMetadata = toStringRecord(rawMetadata)
fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
if (Statsig.initializeCalled()) {
let ev: string = eventName
if (options.lake) {
ev = `lake:${ev}`
}
Statsig.logEvent(ev, null, fullMetadata)
}
/**
* All datalake events should be sent using `logger.metric`, and we don't
* want to double-emit logs to other transports.
*/
if (!options.lake) {
logger.info(eventName, fullMetadata)
Statsig.logEvent(eventName, null, fullMetadata)
}
logger.info(eventName, fullMetadata)
} catch (e) {
// A log should never interrupt the calling code, whatever happens.
logger.error('Failed to log an event', {message: e})
}
}
function toStringRecord<E extends keyof MetricEvents>(
metadata: MetricEvents[E] & FlatJSONRecord,
function toStringRecord<E extends keyof LogEvents>(
metadata: LogEvents[E] & FlatJSONRecord,
): Record<string, string> {
const record: Record<string, string> = {}
for (let key in metadata) {
+1 -1
View File
@@ -323,7 +323,7 @@ export function createBskyAppAbsoluteUrl(path: string): string {
export function createProxiedUrl(url: string): string {
let u
try {
u = URL.parse(url)
u = new URL(url)
} catch {
return url
}
-6
View File
@@ -161,8 +161,6 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
return AppLanguage.ast
case 'ca':
return AppLanguage.ca
case 'cy':
return AppLanguage.cy
case 'da':
return AppLanguage.da
case 'de':
@@ -171,8 +169,6 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
return AppLanguage.el
case 'en-GB':
return AppLanguage.en_GB
case 'eo':
return AppLanguage.eo
case 'es':
return AppLanguage.es
case 'eu':
@@ -183,8 +179,6 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
return AppLanguage.fr
case 'ga':
return AppLanguage.ga
case 'gd':
return AppLanguage.gd
case 'gl':
return AppLanguage.gl
case 'hi':
-27
View File
@@ -14,19 +14,16 @@ import {AppLanguage} from '#/locale/languages'
import {messages as messagesAn} from '#/locale/locales/an/messages'
import {messages as messagesAst} from '#/locale/locales/ast/messages'
import {messages as messagesCa} from '#/locale/locales/ca/messages'
import {messages as messagesCy} from '#/locale/locales/cy/messages'
import {messages as messagesDa} from '#/locale/locales/da/messages'
import {messages as messagesDe} from '#/locale/locales/de/messages'
import {messages as messagesEl} from '#/locale/locales/el/messages'
import {messages as messagesEn} from '#/locale/locales/en/messages'
import {messages as messagesEn_GB} from '#/locale/locales/en-GB/messages'
import {messages as messagesEo} from '#/locale/locales/eo/messages'
import {messages as messagesEs} from '#/locale/locales/es/messages'
import {messages as messagesEu} from '#/locale/locales/eu/messages'
import {messages as messagesFi} from '#/locale/locales/fi/messages'
import {messages as messagesFr} from '#/locale/locales/fr/messages'
import {messages as messagesGa} from '#/locale/locales/ga/messages'
import {messages as messagesGd} from '#/locale/locales/gd/messages'
import {messages as messagesGl} from '#/locale/locales/gl/messages'
import {messages as messagesHi} from '#/locale/locales/hi/messages'
import {messages as messagesHu} from '#/locale/locales/hu/messages'
@@ -81,14 +78,6 @@ export async function dynamicActivate(locale: AppLanguage) {
])
break
}
case AppLanguage.cy: {
i18n.loadAndActivate({locale, messages: messagesCy})
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/cy'),
import('@formatjs/intl-numberformat/locale-data/cy'),
])
break
}
case AppLanguage.da: {
i18n.loadAndActivate({locale, messages: messagesDa})
await Promise.all([
@@ -121,14 +110,6 @@ export async function dynamicActivate(locale: AppLanguage) {
])
break
}
case AppLanguage.eo: {
i18n.loadAndActivate({locale, messages: messagesEo})
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/eo'),
import('@formatjs/intl-numberformat/locale-data/eo'),
])
break
}
case AppLanguage.es: {
i18n.loadAndActivate({locale, messages: messagesEs})
await Promise.all([
@@ -169,14 +150,6 @@ export async function dynamicActivate(locale: AppLanguage) {
])
break
}
case AppLanguage.gd: {
i18n.loadAndActivate({locale, messages: messagesGd})
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/gd'),
import('@formatjs/intl-numberformat/locale-data/gd'),
])
break
}
case AppLanguage.gl: {
i18n.loadAndActivate({locale, messages: messagesGl})
await Promise.all([
-12
View File
@@ -24,10 +24,6 @@ export async function dynamicActivate(locale: AppLanguage) {
mod = await import(`./locales/ca/messages`)
break
}
case AppLanguage.cy: {
mod = await import(`./locales/cy/messages`)
break
}
case AppLanguage.da: {
mod = await import(`./locales/da/messages`)
break
@@ -44,10 +40,6 @@ export async function dynamicActivate(locale: AppLanguage) {
mod = await import(`./locales/en-GB/messages`)
break
}
case AppLanguage.eo: {
mod = await import(`./locales/eo/messages`)
break
}
case AppLanguage.es: {
mod = await import(`./locales/es/messages`)
break
@@ -68,10 +60,6 @@ export async function dynamicActivate(locale: AppLanguage) {
mod = await import(`./locales/ga/messages`)
break
}
case AppLanguage.gd: {
mod = await import(`./locales/gd/messages`)
break
}
case AppLanguage.gl: {
mod = await import(`./locales/gl/messages`)
break
+5 -11
View File
@@ -9,18 +9,15 @@ export enum AppLanguage {
an = 'an',
ast = 'ast',
ca = 'ca',
cy = 'cy',
da = 'da',
de = 'de',
el = 'el',
en_GB = 'en-GB',
eo = 'eo',
es = 'es',
eu = 'eu',
fi = 'fi',
fr = 'fr',
ga = 'ga',
gd = 'gd',
gl = 'gl',
hi = 'hi',
hu = 'hu',
@@ -56,18 +53,15 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
{code2: AppLanguage.an, name: 'Aragonés Aragonese'},
{code2: AppLanguage.ast, name: 'Asturianu Asturian'},
{code2: AppLanguage.ca, name: 'Català Catalan'},
{code2: AppLanguage.cy, name: 'Cymraeg Welsh'},
{code2: AppLanguage.da, name: 'Dansk Danish'},
{code2: AppLanguage.de, name: 'Deutsch German'},
{code2: AppLanguage.el, name: 'Ελληνικά Greek'},
{code2: AppLanguage.en_GB, name: 'English (UK)'},
{code2: AppLanguage.eo, name: 'Esperanto'},
{code2: AppLanguage.es, name: 'Español Spanish'},
{code2: AppLanguage.eu, name: 'Euskera Basque'},
{code2: AppLanguage.fi, name: 'Suomi Finnish'},
{code2: AppLanguage.fr, name: 'Français French'},
{code2: AppLanguage.ga, name: 'Gaeilge Irish'},
{code2: AppLanguage.gd, name: 'Gàidhlig Scottish Gaelic'},
{code2: AppLanguage.gl, name: 'Galego Galician'},
{code2: AppLanguage.hi, name: 'हिंदी Hindi'},
{code2: AppLanguage.hu, name: 'magyar Hungarian'},
@@ -169,7 +163,7 @@ export const LANGUAGES: Language[] = [
{code3: 'cad', code2: '', name: 'Caddo'},
{code3: 'cai', code2: '', name: 'Central American Indian languages'},
{code3: 'car', code2: '', name: 'Galibi Carib'},
{code3: 'cat', code2: 'ca', name: 'Catalan'},
{code3: 'cat', code2: 'ca', name: 'Catalan; Valencian'},
{code3: 'cau', code2: '', name: 'Caucasian languages'},
{code3: 'ceb', code2: '', name: 'Cebuano'},
{code3: 'cel', code2: '', name: 'Celtic languages'},
@@ -224,7 +218,7 @@ export const LANGUAGES: Language[] = [
{code3: 'efi', code2: '', name: 'Efik'},
{code3: 'egy', code2: '', name: 'Egyptian (Ancient)'},
{code3: 'eka', code2: '', name: 'Ekajuk'},
{code3: 'ell', code2: 'el', name: 'Greek'},
{code3: 'ell', code2: 'el', name: 'Greek, Modern (1453-)'},
{code3: 'elx', code2: '', name: 'Elamite'},
{code3: 'eng', code2: 'en', name: 'English'},
{code3: 'enm', code2: '', name: 'English, Middle (1100-1500)'},
@@ -269,8 +263,8 @@ export const LANGUAGES: Language[] = [
{code3: 'gor', code2: '', name: 'Gorontalo'},
{code3: 'got', code2: '', name: 'Gothic'},
{code3: 'grb', code2: '', name: 'Grebo'},
{code3: 'grc', code2: '', name: 'Ancient Greek'},
{code3: 'gre', code2: 'el', name: 'Greek'},
{code3: 'grc', code2: '', name: 'Greek, Ancient (to 1453)'},
{code3: 'gre', code2: 'el', name: 'Greek, Modern (1453-)'},
{code3: 'grn', code2: 'gn', name: 'Guarani'},
{code3: 'gsw', code2: '', name: 'Swiss German; Alemannic; Alsatian'},
{code3: 'guj', code2: 'gu', name: 'Gujarati'},
@@ -367,7 +361,7 @@ export const LANGUAGES: Language[] = [
{code3: 'lit', code2: 'lt', name: 'Lithuanian'},
{code3: 'lol', code2: '', name: 'Mongo'},
{code3: 'loz', code2: '', name: 'Lozi'},
{code3: 'ltz', code2: 'lb', name: 'Luxembourgish'},
{code3: 'ltz', code2: 'lb', name: 'Luxembourgish; Letzeburgesch'},
{code3: 'lua', code2: '', name: 'Luba-Lulua'},
{code3: 'lub', code2: 'lu', name: 'Luba-Katanga'},
{code3: 'lug', code2: 'lg', name: 'Ganda'},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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