Compare commits

...

47 Commits

Author SHA1 Message Date
Eric Bailey 4adeb4befc Patch build version 2025-03-07 13:42:36 -06:00
Paul Frazee d155b718b5 Run intl:extract:all 2025-03-07 10:32:10 -08:00
Eric Bailey 166c56c4cb Fix translation def (#7934) 2025-03-07 12:07:04 -06:00
Paul Frazee 131578ca3a Run intl:pull 2025-03-07 08:41:50 -08:00
Eric Bailey 0014b8b9cd Small revert to be on the safe side (#7932)
* Revert "fix autocomplete (#7930)"

This reverts commit f06a78017a.

* Revert "#6861: Use available space to make focus on Composer TextInput easier (copy) (#7836)"

This reverts commit e40656128d.
2025-03-07 10:34:14 -06:00
Samuel Newman f06a78017a fix autocomplete (#7930) 2025-03-07 10:20:55 -06:00
Eric Bailey 27ae829698 Add plugin to bump gradle jvm heap size (#7922) 2025-03-06 20:21:54 -08:00
Paul Frazee ada2ac8b91 Release 1.99 prep (#7925)
* Revert #7847 (was causing layout issues)

* Test fixes

* Pull latest translations from crowdin

* Run intl:extract:all
2025-03-06 19:45:29 -08:00
Eric Bailey fd8ac1da7d Catch thrown error for autoplay in Safari (#7921) 2025-03-06 19:14:00 -08:00
pfrazee beb0fce56c Nightly source-language update 2025-03-07 02:34:14 +00:00
Samuel Newman ac43b666cb Fix text not wrapping in social proof (#7920)
* Fix text not wrapping in social proof

* fix web too

* add max_w_full atom
2025-03-06 15:54:40 -08:00
Hailey 618c4e4a56 add some metrics for labelers (#7747)
* add some metrics

* add label pref switch metric

* Update src/screens/Profile/Header/ProfileHeaderLabeler.tsx

* Use new logger.metric

* Fix conflicting types

* Remove duplicate toasts

* Move metric to try body

* Move metric to try body

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2025-03-06 14:20:48 -06:00
Samuel Newman 153fc17676 tweak ci (#7916) 2025-03-06 11:49:51 -08:00
Eric Bailey 8eb1f8f1fc Merge #7903 (#7917)
* Add missing msg macro calls for i18n (#7903)

* Add missing msg macro calls for i18n

* Update src/screens/Settings/AppIconSettings/useAppIconSets.ts

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Apply surfdude29's suggestions from code review

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Format

---------

Co-authored-by: Stanislas Signoud <signez@stanisoft.net>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
2025-03-06 12:14:58 -06:00
Seth Wood da2b6f4797 fixing moderation blur gap when replying via the thread feed (#7783) 2025-03-06 12:10:39 -06:00
Samuel Newman 1c16c6ef0a fix cancel button on search posts screen (#7912) 2025-03-06 10:06:13 -08:00
Minseo Lee a7db0bac6f Add context for toast messages (#7902)
* add context for toast

* add
2025-03-06 11:46:09 -06:00
Samuel Newman 29eef6188a Filter searchable people list by accepted (#7915) 2025-03-06 08:34:59 -08:00
pfrazee ba3cff62f1 Nightly source-language update 2025-03-06 02:33:59 +00:00
Eric Bailey 3be87fbf3b Add report dialog e2e tests (#7913) 2025-03-05 16:31:55 -08:00
Samuel Newman 9fde3957e7 ota helper in dev mode (#7911) 2025-03-05 16:01:17 -08:00
surfdude29 dae152da85 Add language: Esperanto (#7863)
* Create messages.po

* Update app.config.js

* Update lingui.config.js

* Update dates.ts

* Update helpers.ts

* Update i18n.ts

* Update i18n.web.ts

* Update languages.ts
2025-03-05 15:57:09 -08:00
surfdude29 a42efd15d7 Add language: Scottish Gaelic (#7864)
* Create messages.po

* Update app.config.js

* Update lingui.config.js

* Update dates.ts

* Update helpers.ts

* Update i18n.ts

* Update i18n.web.ts

* Update languages.ts
2025-03-05 15:56:13 -08:00
surfdude29 d0c2003cc7 Add language: Welsh (#7866)
* Create messages.po

* Update app.config.js

* Update lingui.config.js

* Update dates.ts

* Update helpers.ts

* Update i18n.ts

* Update i18n.web.ts

* Update languages.ts
2025-03-05 15:55:29 -08:00
Paul Frazee 1cfdd2a7a0 Merge branch 'main' of github.com:bluesky-social/social-app into main 2025-03-05 14:39:28 -08:00
Paul Frazee 663f16dfc2 Add initial test for post report flow 2025-03-05 14:39:20 -08:00
surfdude29 947c876fdb mark label for translation (#7908) 2025-03-05 14:32:29 -08:00
Samuel Newman a62fe855c3 skeletons for chat list (#7907) 2025-03-05 09:55:55 -08:00
Samuel Newman c5d9e282e5 fix layout shift in profile feed header (#7906) 2025-03-05 09:54:14 -08:00
Samuel Newman f5f02aa4b3 Add dark mode option to in-app embed snippet dialog (#7377)
* add color theme selection

* change chevron direction

* change chevron direction again

* rm unused ref
2025-03-05 09:50:20 -08:00
Samuel Newman 2d854091b9 enhance(embed): add ability to pin color mode (#7186)
* enhance(embed): add ability to pin color mode

* fix: Move color mode dropdown to the root section

* auto -> system

* style tweaks

* default to light theme

* try and fix eslint

* fix dropdown styles on other browsers

* rm unnecessary eslintrc change

* more explicit color mode select

* make light explicit

---------

Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
2025-03-05 09:24:59 -08:00
pfrazee 01a51c3275 Nightly source-language update 2025-03-05 02:33:32 +00:00
Hailey dda0db9963 use latest macos/xcode (#7893) 2025-03-04 09:24:07 -08:00
Samuel Newman 67c394bc82 Prioritise language codes with regions (#7731)
* prioritise chinese and portugese

* more language name cleanup

* skip ast to fix false positive
2025-03-04 06:03:11 -08:00
Samuel Newman c995eb2f2f DMs inbox (#7778)
* improve error screen

* add chat request prompt

* mock up inbox

* bigger button

* use two-button layout

* get inbox working somewhat

* fix type errors

* fetch both pages for badge

* don't include read convos in preview

* in-chat ui for non-accepted convos (part 1)

* add chatstatusinfo

* fix status info not disappearing

* get chat status info working

* change min item height

* move files around

* add updated sdk

* improve badge behaviour

* mock up mark all as read

* update sdk to 0.14.4

* hide chat status info if initiating convo

* fix unread count for deleted accounts

* add toasts after rejection

* add prompt to delete

* adjust badge on desktop

* requests -> chat requests

* fix height flicker

* add mark as read button to header

* add mark all as read APIs

* separate avatarstack into two components (#7845)

* fix messages being hidden behind chatstatusinfo

* show inbox preview on empty state

* fix empty state again

* Use new convo availability API (#7812)

* [Inbox] Accept button on convo screen (#7795)

* accept button on convo screen

* fix types

* fix type error

* improve spacing

* [DMs] Implement new log types (#7835)

* optimise badge state

* add read message log

* add isLogAcceptConvo

* mute/unmute convo logs

* use setqueriesdata

* always show label on button

* optimistically update badge

* change incorrect unread count change

* Update src/screens/Messages/Inbox.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Messages/components/RequestButtons.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Messages/components/RequestButtons.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Messages/components/RequestListItem.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* fix race condition with accepting convo

* fix back button on web

* filter left convos from badge

* update atproto to fix CI

* Add accept override external to convo (#7891)

* Add accept override external to convo

* rm log

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2025-03-04 05:54:19 -08:00
Samuel Newman 5c14f69566 Modernize search page (take 2) (#7642) 2025-03-04 10:12:34 +00:00
pfrazee 66c09d9953 Nightly source-language update 2025-03-04 02:33:23 +00:00
Samuel Newman c82f768374 fix progress guide location breakpoints (#7887) 2025-03-03 18:57:47 -06:00
Samuel Newman 7222d6fe52 init log using getLog (#7886) 2025-03-03 15:41:13 -08:00
pfrazee 8b6dde17fd Nightly source-language update 2025-03-02 02:34:39 +00:00
Eric Bailey 07dd57d1ea Add metrics to reporting flow (#7871)
* Add metrics to reporting flow

* Remove vestigial state
2025-03-01 13:34:12 -06:00
pfrazee 1c582c59f0 Nightly source-language update 2025-03-01 02:34:56 +00:00
Eric Bailey 84fcbe398b Fix missed logger tests (#7872) 2025-02-28 18:25:49 -06:00
Eric Bailey 3be9dde92d New reporting flow (#7832)
* Add option to align web dialogs to top

* Add new wait util

* Pipe through feed view to feed components

* Reset unneeded change to main

* Copy over fresh report dialog based on old

* Hack in temp testing data

* Swap in new dialog in all cases but chat

* Cleanup

* Add load and initial error state

* Fill in states

* Add copyright link

* Handle single labeler case

* Comment out debug code

* Improve centering of type in circles

* Open details if Other is selected

* Remove debug code

* Tweak colors

* Bump SDK

* Tweak Admonition for better x-platform styles

* Add retry button

* Add close button

* Remove todo not covered in this PR

* Translate Retry
2025-02-28 17:14:02 -06:00
Eric Bailey 96f4f6359a Logger metrics (#7867)
* Adjust datalake abstraction

(cherry picked from commit 8ba6a8d45b1bd5698afbd06d9e858a91789f0ea6)

* Just be really really specific

(cherry picked from commit 920198959659329a7f7f7282a1293aaad198d8e3)

* Add metric method to logger, replace datalake calls with new method

(cherry picked from commit 7a026bbeae75514b64f928d7ff59707c518fd5e5)

* Clarify types

(cherry picked from commit 422b150deb158a70ef37e8a456d91bf26cd0b1bc)
2025-02-28 17:13:49 -06:00
Samuel Newman 9e59a2eef2 Fix starter pack layout (#7869) 2025-02-28 14:50:44 -08:00
bnewbold 462708e207 bskyweb: add robots disallow and 'canonical' config flags (#4760) 2025-02-28 16:09:48 -05:00
178 changed files with 89035 additions and 43459 deletions
+2 -1
View File
@@ -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',
],
+2 -2
View File
@@ -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
View File
@@ -1,2 +1,2 @@
flows:
- "flows/*"
- "flows/**"
+1 -1
View File
@@ -17,7 +17,7 @@ appId: xyz.blueskyweb.app
- inputText: "Post with an image"
- tapOn:
id: "openGalleryBtn"
- tapOn: "Content warnings"
- tapOn: "Allow Full Access"
- tapOn: "Content warnings"
- tapOn: "Porn"
- tapOn:
+2 -4
View File
@@ -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"
+20
View File
@@ -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"
+4
View File
@@ -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

+1
View File
@@ -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)
+7 -1
View File
@@ -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'
+11
View File
@@ -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>");
}
}
+58 -19
View File
@@ -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">
+18 -2
View File
@@ -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({
+14
View File
@@ -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"},
},
},
},
}
+19 -7
View File
@@ -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 {
+3
View File
@@ -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: /
+3
View File
@@ -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
View File
@@ -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",
+34
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
+18 -10
View File
@@ -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({
+41 -11
View File
@@ -1,37 +1,37 @@
import {View} from 'react-native'
import {moderateProfile} from '@atproto/api'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfilesQuery} from '#/state/queries/profile'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import * as bsky from '#/types/bsky'
export function AvatarStack({
profiles,
size = 26,
numPending,
backgroundColor,
}: {
profiles: 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}
/>
)
}
+4 -1
View File
@@ -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
+1 -1
View File
@@ -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')
+22 -9
View File
@@ -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>
)
}
+125 -59
View File
@@ -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>
+2 -2
View File
@@ -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: () => {
+5 -1
View File
@@ -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"
+3 -1
View File
@@ -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])
+33 -12
View File
@@ -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
+3 -3
View File
@@ -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])
+16 -9
View File
@@ -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[] = []
+19 -17
View File
@@ -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
+6
View File
@@ -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,
+5
View File
@@ -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,
],
}
}, [_])
}
+5
View File
@@ -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],
)
}
+3
View File
@@ -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',
+2 -3
View File
@@ -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()
}
+2 -1
View File
@@ -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}
+27 -8
View File
@@ -5,12 +5,12 @@ import {Statsig, StatsigProvider} from 'statsig-react-native-expo'
import {BUNDLE_DATE, BUNDLE_IDENTIFIER, IS_TESTFLIGHT} from '#/lib/app-info'
import {logger} from '#/logger'
import {MetricEvents} from '#/logger/metrics'
import {isWeb} from '#/platform/detection'
import * as persisted from '#/state/persisted'
import {useSession} from '../../state/session'
import {timeout} from '../async/timeout'
import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback'
import {LogEvents} from './events'
import {Gate} from './gates'
const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV'
@@ -42,7 +42,7 @@ if (isWeb && typeof window !== 'undefined') {
refUrl = decodeURIComponent(params.get('ref_url') ?? '')
}
export type {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) {
+6
View File
@@ -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':
+27
View File
@@ -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([
+12
View File
@@ -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
View File
@@ -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'},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -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