Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4adeb4befc | |||
| d155b718b5 | |||
| 166c56c4cb | |||
| 131578ca3a | |||
| 0014b8b9cd | |||
| f06a78017a | |||
| 27ae829698 | |||
| ada2ac8b91 | |||
| fd8ac1da7d | |||
| beb0fce56c | |||
| ac43b666cb | |||
| 618c4e4a56 | |||
| 153fc17676 | |||
| 8eb1f8f1fc | |||
| da2b6f4797 | |||
| 1c16c6ef0a | |||
| a7db0bac6f | |||
| 29eef6188a | |||
| ba3cff62f1 | |||
| 3be87fbf3b | |||
| 9fde3957e7 | |||
| dae152da85 | |||
| a42efd15d7 | |||
| d0c2003cc7 | |||
| 1cfdd2a7a0 | |||
| 663f16dfc2 | |||
| 947c876fdb | |||
| a62fe855c3 | |||
| c5d9e282e5 | |||
| f5f02aa4b3 | |||
| 2d854091b9 | |||
| 01a51c3275 | |||
| dda0db9963 | |||
| 67c394bc82 | |||
| c995eb2f2f | |||
| 5c14f69566 | |||
| 66c09d9953 | |||
| c82f768374 | |||
| 7222d6fe52 | |||
| 8b6dde17fd | |||
| 07dd57d1ea | |||
| 1c582c59f0 | |||
| 84fcbe398b | |||
| 3be9dde92d | |||
| 96f4f6359a | |||
| 9e59a2eef2 | |||
| 462708e207 |
+2
-1
@@ -32,6 +32,7 @@ module.exports = {
|
||||
'H6',
|
||||
'P',
|
||||
'Admonition',
|
||||
'Admonition.Admonition',
|
||||
],
|
||||
impliedTextProps: [],
|
||||
suggestedTextWrappers: {
|
||||
@@ -91,9 +92,9 @@ module.exports = {
|
||||
'*.lock',
|
||||
'.husky',
|
||||
'patches',
|
||||
'bskyweb',
|
||||
'*.html',
|
||||
'bskyweb',
|
||||
'bskyembed',
|
||||
'src/locale/locales/_build/',
|
||||
'src/locale/locales/**/*.js',
|
||||
],
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
name: Build and Submit iOS
|
||||
runs-on: macos-14
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: '15.3'
|
||||
xcode-version: '16.2'
|
||||
|
||||
- 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-14
|
||||
runs-on: macos-15
|
||||
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: '15.3'
|
||||
xcode-version: '16.2'
|
||||
|
||||
- name: ☕️ Setup Cocoapods
|
||||
uses: maxim-lobanov/setup-cocoapods@v1
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
flows:
|
||||
- "flows/*"
|
||||
- "flows/**"
|
||||
|
||||
@@ -17,7 +17,7 @@ appId: xyz.blueskyweb.app
|
||||
- inputText: "Post with an image"
|
||||
- tapOn:
|
||||
id: "openGalleryBtn"
|
||||
- tapOn: "Content warnings"
|
||||
- tapOn: "Allow Full Access"
|
||||
- tapOn: "Content warnings"
|
||||
- tapOn: "Porn"
|
||||
- tapOn:
|
||||
|
||||
@@ -24,12 +24,10 @@ appId: xyz.blueskyweb.app
|
||||
- inputText: "example@test.com"
|
||||
- tapOn:
|
||||
id: "passwordInput"
|
||||
- inputText: "hunter2"
|
||||
- pressKey: Enter
|
||||
- inputText: "hunter22"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
id: "nextBtn"
|
||||
- tapOn:
|
||||
id: "handleInput"
|
||||
- inputText: "e2e-test"
|
||||
- tapOn:
|
||||
id: "nextBtn"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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%"
|
||||
@@ -0,0 +1,24 @@
|
||||
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"
|
||||
@@ -0,0 +1,24 @@
|
||||
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"
|
||||
@@ -0,0 +1,35 @@
|
||||
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"
|
||||
@@ -0,0 +1,30 @@
|
||||
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"
|
||||
@@ -75,14 +75,17 @@ module.exports = function (config) {
|
||||
'an',
|
||||
'ast',
|
||||
'ca',
|
||||
'cy',
|
||||
'da',
|
||||
'de',
|
||||
'el',
|
||||
'eo',
|
||||
'es',
|
||||
'eu',
|
||||
'fi',
|
||||
'fr',
|
||||
'ga',
|
||||
'gd',
|
||||
'gl',
|
||||
'hi',
|
||||
'hu',
|
||||
@@ -237,6 +240,7 @@ module.exports = function (config) {
|
||||
},
|
||||
],
|
||||
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
|
||||
'./plugins/withGradleJVMHeapSizeIncrease.js',
|
||||
'./plugins/withAndroidManifestPlugin.js',
|
||||
'./plugins/withAndroidManifestFCMIconPlugin.js',
|
||||
'./plugins/withAndroidStylesAccentColorPlugin.js',
|
||||
|
||||
@@ -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="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>
|
||||
|
After Width: | Height: | Size: 454 B |
@@ -68,6 +68,7 @@ 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,9 +1,15 @@
|
||||
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 initColorMode() {
|
||||
export function initSystemColorMode() {
|
||||
applyTheme(
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
|
||||
@@ -9,3 +9,14 @@
|
||||
: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>");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import '../index.css'
|
||||
|
||||
import {AppBskyFeedDefs, AppBskyFeedPost, AtUri, BskyAgent} from '@atproto/api'
|
||||
import {AppBskyFeedDefs, AppBskyFeedPost, AtpAgent, AtUri} 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 {initColorMode} from '../color-mode'
|
||||
import {
|
||||
assertColorModeValues,
|
||||
ColorModeValues,
|
||||
initSystemColorMode,
|
||||
} from '../color-mode'
|
||||
import {Container} from '../components/container'
|
||||
import {Link} from '../components/link'
|
||||
import {Post} from '../components/post'
|
||||
@@ -22,9 +26,9 @@ export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
|
||||
const root = document.getElementById('app')
|
||||
if (!root) throw new Error('No root element')
|
||||
|
||||
initColorMode()
|
||||
initSystemColorMode()
|
||||
|
||||
const agent = new BskyAgent({
|
||||
const agent = new AtpAgent({
|
||||
service: 'https://public.api.bsky.app',
|
||||
})
|
||||
|
||||
@@ -32,6 +36,7 @@ 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>(
|
||||
@@ -120,24 +125,50 @@ function LandingPage() {
|
||||
|
||||
<h1 className="text-4xl font-bold text-center">Embed a Bluesky Post</h1>
|
||||
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<img src={arrowBottom} className="w-6 dark:invert" />
|
||||
|
||||
{loading ? (
|
||||
<div className="w-full max-w-[600px]">
|
||||
<div className={`${colorMode} 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} />}
|
||||
{!error && thread && <Post thread={thread} key={thread.post.uri} />}
|
||||
{!error && thread && uri && (
|
||||
<Snippet thread={thread} colorMode={colorMode} />
|
||||
)}
|
||||
<div className={colorMode}>
|
||||
{!error && thread && <Post thread={thread} key={thread.post.uri} />}
|
||||
</div>
|
||||
{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>
|
||||
@@ -168,7 +199,13 @@ function Skeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
|
||||
function Snippet({
|
||||
thread,
|
||||
colorMode,
|
||||
}: {
|
||||
thread: AppBskyFeedDefs.ThreadViewPost
|
||||
colorMode: ColorModeValues
|
||||
}) {
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
@@ -204,9 +241,11 @@ function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
|
||||
// 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)}"><p lang="${escapeHtml(
|
||||
lang,
|
||||
)}">${escapeHtml(record.text)}${
|
||||
)}" data-bluesky-cid="${escapeHtml(
|
||||
thread.post.cid,
|
||||
)}" data-bluesky-embed-color-mode="${escapeHtml(
|
||||
colorMode,
|
||||
)}"><p lang="${escapeHtml(lang)}">${escapeHtml(record.text)}${
|
||||
record.embed
|
||||
? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>`
|
||||
: ''
|
||||
@@ -217,7 +256,7 @@ function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
|
||||
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
|
||||
niceDate(thread.post.indexedAt),
|
||||
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
|
||||
}, [thread])
|
||||
}, [thread, colorMode])
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 w-full">
|
||||
|
||||
@@ -4,7 +4,7 @@ import {AppBskyFeedDefs, AtpAgent} from '@atproto/api'
|
||||
import {h, render} from 'preact'
|
||||
|
||||
import logo from '../../assets/logo.svg'
|
||||
import {initColorMode} from '../color-mode'
|
||||
import {applyTheme, initSystemColorMode} from '../color-mode'
|
||||
import {Container} from '../components/container'
|
||||
import {Link} from '../components/link'
|
||||
import {Post} from '../components/post'
|
||||
@@ -22,7 +22,23 @@ if (!uri) {
|
||||
throw new Error('No uri in path')
|
||||
}
|
||||
|
||||
initColorMode()
|
||||
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
|
||||
}
|
||||
|
||||
agent
|
||||
.getPostThread({
|
||||
|
||||
@@ -94,6 +94,20 @@ 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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ 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()
|
||||
@@ -204,13 +206,23 @@ func serve(cctx *cli.Context) error {
|
||||
return http.FS(fsys)
|
||||
}())
|
||||
|
||||
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")
|
||||
})
|
||||
// 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("/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 {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# 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: /
|
||||
@@ -5,15 +5,18 @@ module.exports = {
|
||||
'an',
|
||||
'ast',
|
||||
'ca',
|
||||
'cy',
|
||||
'da',
|
||||
'de',
|
||||
'el',
|
||||
'en-GB',
|
||||
'eo',
|
||||
'es',
|
||||
'eu',
|
||||
'fi',
|
||||
'fr',
|
||||
'ga',
|
||||
'gd',
|
||||
'gl',
|
||||
'hi',
|
||||
'hu',
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.99.0",
|
||||
"version": "1.99.1",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -57,7 +57,7 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.14.0",
|
||||
"@atproto/api": "^0.14.7",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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
|
||||
}
|
||||
+9
-2
@@ -32,6 +32,7 @@ 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'
|
||||
@@ -68,6 +69,7 @@ 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'
|
||||
@@ -410,6 +412,11 @@ 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}
|
||||
@@ -729,7 +736,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
linking={LINKING}
|
||||
theme={theme}
|
||||
onStateChange={() => {
|
||||
logEvent('lake:router:navigate', {
|
||||
logger.metric('router:navigate', {
|
||||
from: prevLoggedRouteName.current,
|
||||
})
|
||||
prevLoggedRouteName.current = getCurrentRouteName()
|
||||
@@ -738,7 +745,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
attachRouteToLogEvents(getCurrentRouteName)
|
||||
logModuleInitTime()
|
||||
onReady()
|
||||
logEvent('lake:router:navigate', {})
|
||||
logger.metric('router:navigate', {})
|
||||
}}>
|
||||
{children}
|
||||
</NavigationContainer>
|
||||
|
||||
+22
-1
@@ -31,6 +31,18 @@ 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,
|
||||
},
|
||||
@@ -58,7 +70,7 @@ export const atoms = {
|
||||
}),
|
||||
|
||||
/*
|
||||
* Width
|
||||
* Width & Height
|
||||
*/
|
||||
w_full: {
|
||||
width: '100%',
|
||||
@@ -69,6 +81,12 @@ 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
|
||||
@@ -93,6 +111,9 @@ export const atoms = {
|
||||
/*
|
||||
* Border radius
|
||||
*/
|
||||
rounded_0: {
|
||||
borderRadius: 0,
|
||||
},
|
||||
rounded_2xs: {
|
||||
borderRadius: tokens.borderRadius._2xs,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ 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'
|
||||
@@ -49,22 +50,29 @@ export function Text({
|
||||
return (
|
||||
<BaseText
|
||||
{...rest}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
{
|
||||
paddingTop: 1,
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
style={[a.flex_1, a.text_sm, a.leading_snug, a.pr_md, 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_row, a.gap_sm]}>{children}</View>
|
||||
return (
|
||||
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Outer({
|
||||
|
||||
@@ -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: string[]
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
size?: number
|
||||
numPending?: number
|
||||
backgroundColor?: string
|
||||
}) {
|
||||
const halfSize = size / 2
|
||||
const {data, error} = useProfilesQuery({handles: profiles})
|
||||
const t = useTheme()
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
if (error) {
|
||||
console.error(error)
|
||||
return null
|
||||
}
|
||||
|
||||
const isPending = !data || !moderationOpts
|
||||
const isPending = (numPending && profiles.length === 0) || !moderationOpts
|
||||
|
||||
const items = isPending
|
||||
? Array.from({length: profiles.length}).map((_, i) => ({
|
||||
? Array.from({length: numPending ?? profiles.length}).map((_, i) => ({
|
||||
key: i,
|
||||
profile: null,
|
||||
moderation: null,
|
||||
}))
|
||||
: data.profiles.map(item => ({
|
||||
: 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: t.atoms.bg.backgroundColor,
|
||||
borderColor: backgroundColor ?? t.atoms.bg.backgroundColor,
|
||||
borderRadius: 999,
|
||||
zIndex: 3 - i,
|
||||
},
|
||||
@@ -74,3 +74,33 @@ 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,7 +68,10 @@ export type DialogOuterProps = {
|
||||
testID?: string
|
||||
}
|
||||
|
||||
type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T
|
||||
type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> &
|
||||
T & {
|
||||
testID?: string
|
||||
}
|
||||
export type DialogInnerProps =
|
||||
| DialogInnerPropsBase<{
|
||||
label?: undefined
|
||||
|
||||
@@ -269,7 +269,7 @@ function SaveButtonInner({
|
||||
},
|
||||
])
|
||||
}
|
||||
Toast.show(_(msg`Feeds updated!`))
|
||||
Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'})))
|
||||
} catch (err: any) {
|
||||
logger.error(err, {message: `FeedCard: failed to update feeds`, pin})
|
||||
Toast.show(_(msg`Failed to update feeds`), 'xmark')
|
||||
|
||||
@@ -33,11 +33,13 @@ 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(),
|
||||
@@ -64,11 +66,12 @@ export function KnownFollowers({
|
||||
moderationOpts={moderationOpts}
|
||||
onLinkPress={onLinkPress}
|
||||
minimal={minimal}
|
||||
showIfEmpty={showIfEmpty}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
return <EmptyFallback show={showIfEmpty} />
|
||||
}
|
||||
|
||||
function KnownFollowersInner({
|
||||
@@ -77,22 +80,19 @@ 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.flex_1,
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]
|
||||
const textStyle = [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 null
|
||||
if (slice.length === 0) return <EmptyFallback show={showIfEmpty} />
|
||||
|
||||
const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE
|
||||
|
||||
@@ -127,7 +127,7 @@ function KnownFollowersInner({
|
||||
onPress={onLinkPress}
|
||||
to={makeProfileLink(profile, 'known-followers')}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.max_w_full,
|
||||
a.flex_row,
|
||||
minimal ? a.gap_sm : a.gap_md,
|
||||
a.align_center,
|
||||
@@ -172,6 +172,7 @@ function KnownFollowersInner({
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.flex_shrink,
|
||||
textStyle,
|
||||
hovered && {
|
||||
textDecorationLine: 'underline',
|
||||
@@ -243,3 +244,15 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {memo, useRef, useState} from 'react'
|
||||
import {TextInput, View} from 'react-native'
|
||||
import {memo, useEffect, useMemo, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -8,12 +8,19 @@ 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 {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets'
|
||||
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 {Text} from '#/components/Typography'
|
||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||
|
||||
export type ColorModeValues = 'system' | 'light' | 'dark'
|
||||
|
||||
type EmbedDialogProps = {
|
||||
control: Dialog.DialogControlProps
|
||||
@@ -44,11 +51,12 @@ 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
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (copied) {
|
||||
const timeout = setTimeout(() => {
|
||||
setCopied(false)
|
||||
@@ -57,7 +65,7 @@ function EmbedDialogInner({
|
||||
}
|
||||
}, [copied])
|
||||
|
||||
const snippet = React.useMemo(() => {
|
||||
const snippet = useMemo(() => {
|
||||
function toEmbedUrl(href: string) {
|
||||
return toShareUrl(href) + '?ref_src=embed'
|
||||
}
|
||||
@@ -75,9 +83,11 @@ 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)}"><p lang="${escapeHtml(
|
||||
lang,
|
||||
)}">${escapeHtml(record.text)}${
|
||||
)}" data-bluesky-cid="${escapeHtml(
|
||||
postCid,
|
||||
)}" data-bluesky-embed-color-mode="${escapeHtml(
|
||||
colorMode,
|
||||
)}"><p lang="${escapeHtml(lang)}">${escapeHtml(record.text)}${
|
||||
record.embed
|
||||
? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>`
|
||||
: ''
|
||||
@@ -88,60 +98,116 @@ 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])
|
||||
}, [i18n, postUri, postCid, record, timestamp, postAuthor, colorMode])
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>Copied!</Trans>
|
||||
</ButtonText>
|
||||
</>
|
||||
) : (
|
||||
<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>Copy code</Trans>
|
||||
<Trans>Customization options</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</Button>
|
||||
</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>
|
||||
</>
|
||||
) : (
|
||||
<ButtonText>
|
||||
<Trans>Copy code</Trans>
|
||||
</ButtonText>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</Dialog.Inner>
|
||||
|
||||
@@ -173,9 +173,9 @@ function MenuContent({
|
||||
const {mutate: muteConvo} = useMuteConvo(convoId, {
|
||||
onSuccess: data => {
|
||||
if (data.convo.muted) {
|
||||
Toast.show(_(msg`Chat muted`))
|
||||
Toast.show(_(msg({message: 'Chat muted', context: 'toast'})))
|
||||
} else {
|
||||
Toast.show(_(msg`Chat unmuted`))
|
||||
Toast.show(_(msg({message: 'Chat unmuted', context: 'toast'})))
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
|
||||
@@ -13,10 +13,12 @@ export function LeaveConvoPrompt({
|
||||
control,
|
||||
convoId,
|
||||
currentScreen,
|
||||
hasMessages = true,
|
||||
}: {
|
||||
control: DialogOuterProps['control']
|
||||
convoId: string
|
||||
currentScreen: 'list' | 'conversation'
|
||||
hasMessages?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
@@ -39,7 +41,9 @@ export function LeaveConvoPrompt({
|
||||
control={control}
|
||||
title={_(msg`Leave conversation`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`,
|
||||
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?`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Leave`)}
|
||||
confirmButtonColor="negative"
|
||||
|
||||
@@ -69,7 +69,9 @@ export let MessageMenu = ({
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
convo
|
||||
.deleteMessage(message.id)
|
||||
.then(() => Toast.show(_(msg`Message deleted`)))
|
||||
.then(() =>
|
||||
Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))),
|
||||
)
|
||||
.catch(() => Toast.show(_(msg`Failed to delete message`)))
|
||||
}, [_, convo, message.id])
|
||||
|
||||
|
||||
@@ -8,13 +8,15 @@ 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 {useMaybeConvoForUser} from '#/state/queries/messages/get-convo-for-members'
|
||||
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 {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,
|
||||
@@ -27,10 +29,19 @@ export function MessageProfileButton({
|
||||
const {needsEmailVerification} = useEmail()
|
||||
const verifyEmailControl = useDialogControl()
|
||||
|
||||
const {data: convo, isPending} = useMaybeConvoForUser(profile.did)
|
||||
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 onPress = React.useCallback(() => {
|
||||
if (!convo?.id) {
|
||||
if (!convoAvailability?.canChat) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -39,15 +50,25 @@ export function MessageProfileButton({
|
||||
return
|
||||
}
|
||||
|
||||
if (convo && !convo.lastMessage) {
|
||||
if (convoAvailability.convo) {
|
||||
logEvent('chat:open', {logContext: 'ProfileHeader'})
|
||||
navigation.navigate('MessagesConversation', {
|
||||
conversation: convoAvailability.convo.id,
|
||||
})
|
||||
} else {
|
||||
logEvent('chat:create', {logContext: 'ProfileHeader'})
|
||||
initiateConvo([profile.did])
|
||||
}
|
||||
logEvent('chat:open', {logContext: 'ProfileHeader'})
|
||||
}, [
|
||||
needsEmailVerification,
|
||||
verifyEmailControl,
|
||||
navigation,
|
||||
profile.did,
|
||||
initiateConvo,
|
||||
convoAvailability,
|
||||
])
|
||||
|
||||
navigation.navigate('MessagesConversation', {conversation: convo.id})
|
||||
}, [needsEmailVerification, verifyEmailControl, convo, navigation])
|
||||
|
||||
if (isPending) {
|
||||
if (!convoAvailability) {
|
||||
// show pending state based on declaration
|
||||
if (canBeMessaged(profile)) {
|
||||
return (
|
||||
@@ -69,7 +90,7 @@ export function MessageProfileButton({
|
||||
}
|
||||
}
|
||||
|
||||
if (convo) {
|
||||
if (convoAvailability.canChat) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -53,10 +53,10 @@ export let MessagesListHeader = ({
|
||||
}, [moderation])
|
||||
|
||||
const onPressBack = useCallback(() => {
|
||||
if (isWeb) {
|
||||
navigation.replace('Messages', {})
|
||||
} else {
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.navigate('Messages', {})
|
||||
}
|
||||
}, [navigation])
|
||||
|
||||
|
||||
@@ -311,6 +311,19 @@ 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')) {
|
||||
@@ -319,18 +332,12 @@ 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,7 +77,10 @@ export function SearchablePeopleList({
|
||||
isFetching,
|
||||
} = useActorAutocompleteQuery(searchText, true, 12)
|
||||
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
|
||||
const {data: convos} = useListConvosQuery({enabled: showRecentConvos})
|
||||
const {data: convos} = useListConvosQuery({
|
||||
enabled: showRecentConvos,
|
||||
status: 'accepted',
|
||||
})
|
||||
|
||||
const items = useMemo(() => {
|
||||
let _items: Item[] = []
|
||||
|
||||
@@ -41,25 +41,27 @@ export function HostingProvider({
|
||||
onSelect={onSelectServiceUrl}
|
||||
/>
|
||||
{minimal ? (
|
||||
<View style={[a.flex_row, a.align_center, a.flex_wrap]}>
|
||||
<View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_xs]}>
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
<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>
|
||||
<Trans>You are creating an account on</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
|
||||
|
||||
@@ -11,14 +11,17 @@ 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,
|
||||
@@ -53,15 +56,18 @@ 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,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
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`Appeal submitted`))
|
||||
Toast.show(_(msg({message: 'Appeal submitted', context: 'toast'})))
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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`,
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
|
||||
@@ -0,0 +1,49 @@
|
||||
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])
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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')),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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,
|
||||
],
|
||||
}
|
||||
}, [_])
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
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],
|
||||
)
|
||||
}
|
||||
@@ -168,6 +168,9 @@ 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',
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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,
|
||||
@@ -17,7 +16,7 @@ export async function compressVideo(
|
||||
const blob = base64ToBlob(base64, mimeType)
|
||||
const uri = URL.createObjectURL(blob)
|
||||
|
||||
if (blob.size > MAX_VIDEO_SIZE) {
|
||||
if (blob.size > VIDEO_MAX_SIZE) {
|
||||
throw new VideoTooLargeError()
|
||||
}
|
||||
|
||||
|
||||
@@ -56,8 +56,9 @@ export type CommonNavigatorParams = {
|
||||
Search: {q?: string}
|
||||
Hashtag: {tag: string; author?: string}
|
||||
Topic: {topic: string}
|
||||
MessagesConversation: {conversation: string; embed?: string}
|
||||
MessagesConversation: {conversation: string; embed?: string; accept?: true}
|
||||
MessagesSettings: undefined
|
||||
MessagesInbox: undefined
|
||||
NotificationSettings: undefined
|
||||
Feeds: undefined
|
||||
Start: {name: string; rkey: string}
|
||||
|
||||
@@ -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 {LogEvents}
|
||||
export type {MetricEvents as LogEvents}
|
||||
|
||||
function createStatsigOptions(prefetchUsers: StatsigUser[]) {
|
||||
return {
|
||||
@@ -91,25 +91,44 @@ export function toClout(n: number | null | undefined): number | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
export function logEvent<E extends keyof LogEvents>(
|
||||
/**
|
||||
* @deprecated use `logger.metric()` instead
|
||||
*/
|
||||
export function logEvent<E extends keyof MetricEvents>(
|
||||
eventName: E & string,
|
||||
rawMetadata: LogEvents[E] & FlatJSONRecord,
|
||||
rawMetadata: MetricEvents[E] & FlatJSONRecord,
|
||||
options: {
|
||||
/**
|
||||
* Send to our data lake only, not to StatSig
|
||||
*/
|
||||
lake?: boolean
|
||||
} = {lake: false},
|
||||
) {
|
||||
try {
|
||||
const fullMetadata = toStringRecord(rawMetadata)
|
||||
fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
|
||||
if (Statsig.initializeCalled()) {
|
||||
Statsig.logEvent(eventName, null, fullMetadata)
|
||||
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)
|
||||
}
|
||||
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 LogEvents>(
|
||||
metadata: LogEvents[E] & FlatJSONRecord,
|
||||
function toStringRecord<E extends keyof MetricEvents>(
|
||||
metadata: MetricEvents[E] & FlatJSONRecord,
|
||||
): Record<string, string> {
|
||||
const record: Record<string, string> = {}
|
||||
for (let key in metadata) {
|
||||
|
||||
@@ -161,6 +161,8 @@ 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':
|
||||
@@ -169,6 +171,8 @@ 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':
|
||||
@@ -179,6 +183,8 @@ 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':
|
||||
|
||||
@@ -14,16 +14,19 @@ 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'
|
||||
@@ -78,6 +81,14 @@ 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([
|
||||
@@ -110,6 +121,14 @@ 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([
|
||||
@@ -150,6 +169,14 @@ 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([
|
||||
|
||||
@@ -24,6 +24,10 @@ 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
|
||||
@@ -40,6 +44,10 @@ 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
|
||||
@@ -60,6 +68,10 @@ 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
|
||||
|
||||
+11
-5
@@ -9,15 +9,18 @@ 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',
|
||||
@@ -53,15 +56,18 @@ 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'},
|
||||
@@ -163,7 +169,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; Valencian'},
|
||||
{code3: 'cat', code2: 'ca', name: 'Catalan'},
|
||||
{code3: 'cau', code2: '', name: 'Caucasian languages'},
|
||||
{code3: 'ceb', code2: '', name: 'Cebuano'},
|
||||
{code3: 'cel', code2: '', name: 'Celtic languages'},
|
||||
@@ -218,7 +224,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, Modern (1453-)'},
|
||||
{code3: 'ell', code2: 'el', name: 'Greek'},
|
||||
{code3: 'elx', code2: '', name: 'Elamite'},
|
||||
{code3: 'eng', code2: 'en', name: 'English'},
|
||||
{code3: 'enm', code2: '', name: 'English, Middle (1100-1500)'},
|
||||
@@ -263,8 +269,8 @@ export const LANGUAGES: Language[] = [
|
||||
{code3: 'gor', code2: '', name: 'Gorontalo'},
|
||||
{code3: 'got', code2: '', name: 'Gothic'},
|
||||
{code3: 'grb', code2: '', name: 'Grebo'},
|
||||
{code3: 'grc', code2: '', name: 'Greek, Ancient (to 1453)'},
|
||||
{code3: 'gre', code2: 'el', name: 'Greek, Modern (1453-)'},
|
||||
{code3: 'grc', code2: '', name: 'Ancient Greek'},
|
||||
{code3: 'gre', code2: 'el', name: 'Greek'},
|
||||
{code3: 'grn', code2: 'gn', name: 'Guarani'},
|
||||
{code3: 'gsw', code2: '', name: 'Swiss German; Alemannic; Alsatian'},
|
||||
{code3: 'guj', code2: 'gu', name: 'Gujarati'},
|
||||
@@ -361,7 +367,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; Letzeburgesch'},
|
||||
{code3: 'ltz', code2: 'lb', name: 'Luxembourgish'},
|
||||
{code3: 'lua', code2: '', name: 'Luba-Lulua'},
|
||||
{code3: 'lub', code2: 'lu', name: 'Luba-Katanga'},
|
||||
{code3: 'lug', code2: 'lg', name: 'Ganda'},
|
||||
|
||||
+1628
-1208
File diff suppressed because it is too large
Load Diff
+1562
-1142
File diff suppressed because it is too large
Load Diff
+1582
-1162
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1630
-1210
File diff suppressed because it is too large
Load Diff
+1555
-1135
File diff suppressed because it is too large
Load Diff
+1567
-1147
File diff suppressed because it is too large
Load Diff
+1565
-1145
File diff suppressed because it is too large
Load Diff
+985
-583
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1729
-1309
File diff suppressed because it is too large
Load Diff
+1586
-1166
File diff suppressed because it is too large
Load Diff
+1576
-1156
File diff suppressed because it is too large
Load Diff
+1557
-1137
File diff suppressed because it is too large
Load Diff
+1577
-1157
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1771
-1351
File diff suppressed because it is too large
Load Diff
+1573
-1153
File diff suppressed because it is too large
Load Diff
+1555
-1135
File diff suppressed because it is too large
Load Diff
+1584
-1164
File diff suppressed because it is too large
Load Diff
+1948
-1528
File diff suppressed because it is too large
Load Diff
+1571
-1151
File diff suppressed because it is too large
Load Diff
+1634
-1214
File diff suppressed because it is too large
Load Diff
+1561
-1141
File diff suppressed because it is too large
Load Diff
+1563
-1143
File diff suppressed because it is too large
Load Diff
+1566
-1146
File diff suppressed because it is too large
Load Diff
+1642
-1222
File diff suppressed because it is too large
Load Diff
+1625
-1205
File diff suppressed because it is too large
Load Diff
+1763
-1343
File diff suppressed because it is too large
Load Diff
+1695
-1275
File diff suppressed because it is too large
Load Diff
+1731
-1311
File diff suppressed because it is too large
Load Diff
+1569
-1149
File diff suppressed because it is too large
Load Diff
+1564
-1144
File diff suppressed because it is too large
Load Diff
+1600
-1180
File diff suppressed because it is too large
Load Diff
+1563
-1143
File diff suppressed because it is too large
Load Diff
+1588
-1168
File diff suppressed because it is too large
Load Diff
+1590
-1170
File diff suppressed because it is too large
Load Diff
+1652
-1232
File diff suppressed because it is too large
Load Diff
+1609
-1189
File diff suppressed because it is too large
Load Diff
@@ -120,7 +120,7 @@ describe('general functionality', () => {
|
||||
expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
|
||||
category: Logger.Context.Default,
|
||||
message,
|
||||
data: {context: 'logger'},
|
||||
data: {__context__: 'logger'},
|
||||
type: 'default',
|
||||
level: LogLevel.Debug,
|
||||
timestamp: sentryTimestamp,
|
||||
@@ -136,7 +136,7 @@ describe('general functionality', () => {
|
||||
expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
|
||||
category: Logger.Context.Default,
|
||||
message,
|
||||
data: {prop: true, context: 'logger'},
|
||||
data: {prop: true, __context__: 'logger'},
|
||||
type: 'info',
|
||||
level: LogLevel.Info,
|
||||
timestamp: sentryTimestamp,
|
||||
@@ -152,7 +152,7 @@ describe('general functionality', () => {
|
||||
expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
|
||||
category: Logger.Context.Default,
|
||||
message,
|
||||
data: {context: 'logger'},
|
||||
data: {__context__: 'logger'},
|
||||
type: 'default',
|
||||
level: 'debug', // Sentry bug, log becomes debug
|
||||
timestamp: sentryTimestamp,
|
||||
@@ -161,7 +161,7 @@ describe('general functionality', () => {
|
||||
expect(Sentry.captureMessage).toHaveBeenCalledWith(message, {
|
||||
level: 'log',
|
||||
tags: {category: 'logger'},
|
||||
extra: {context: 'logger'},
|
||||
extra: {__context__: 'logger'},
|
||||
})
|
||||
|
||||
sentryTransport(
|
||||
@@ -174,7 +174,7 @@ describe('general functionality', () => {
|
||||
expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
|
||||
category: Logger.Context.Default,
|
||||
message,
|
||||
data: {context: 'logger'},
|
||||
data: {__context__: 'logger'},
|
||||
type: 'default',
|
||||
level: 'warning',
|
||||
timestamp: sentryTimestamp,
|
||||
@@ -183,7 +183,7 @@ describe('general functionality', () => {
|
||||
expect(Sentry.captureMessage).toHaveBeenCalledWith(message, {
|
||||
level: 'warning',
|
||||
tags: {category: 'logger'},
|
||||
extra: {context: 'logger'},
|
||||
extra: {__context__: 'logger'},
|
||||
})
|
||||
|
||||
const e = new Error('error')
|
||||
@@ -209,7 +209,7 @@ describe('general functionality', () => {
|
||||
},
|
||||
extra: {
|
||||
prop: true,
|
||||
context: 'logger',
|
||||
__context__: 'logger',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user