+1
-1
.eslintrc.js
+1
-1
.eslintrc.js
+2
README.md
+2
README.md
···
76
76
77
77
See [./LICENSE](./LICENSE) for the full license.
78
78
79
+
Bluesky Social PBC has committed to a software patent non-aggression pledge. For details see [the original announcement](https://bsky.social/about/blog/10-01-2025-patent-pledge).
80
+
79
81
## P.S.
80
82
81
83
We ❤️ you and all of the ways you support us. Thank you for making Bluesky a great place!
+3
-3
package.json
+3
-3
package.json
···
71
71
"icons:optimize": "svgo -f ./assets/icons"
72
72
},
73
73
"dependencies": {
74
-
"@atproto/api": "^0.16.7",
74
+
"@atproto/api": "^0.17.0",
75
75
"@bitdrift/react-native": "^0.6.8",
76
76
"@braintree/sanitize-url": "^6.0.2",
77
77
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
···
245
245
"babel-jest": "^29.7.0",
246
246
"babel-plugin-macros": "^3.1.0",
247
247
"babel-plugin-module-resolver": "^5.0.2",
248
-
"babel-plugin-react-compiler": "^19.1.0-rc.1",
248
+
"babel-plugin-react-compiler": "^19.1.0-rc.3",
249
249
"babel-preset-expo": "~54.0.0",
250
250
"eslint": "^8.19.0",
251
251
"eslint-plugin-bsky-internal": "link:./eslint",
···
253
253
"eslint-plugin-import": "^2.31.0",
254
254
"eslint-plugin-lingui": "^0.2.0",
255
255
"eslint-plugin-react": "^7.33.2",
256
-
"eslint-plugin-react-compiler": "^19.1.0-rc.1",
256
+
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
257
257
"eslint-plugin-react-native-a11y": "^3.3.0",
258
258
"eslint-plugin-simple-import-sort": "^12.0.0",
259
259
"file-loader": "6.2.0",
-1
src/components/dialogs/StarterPackDialog.tsx
-1
src/components/dialogs/StarterPackDialog.tsx
+1
-1
src/lib/hooks/useIntentHandler.ts
+1
-1
src/lib/hooks/useIntentHandler.ts
···
51
51
}
52
52
53
53
const urlp = new URL(url)
54
-
const [_, intent, intentType] = urlp.pathname.split('/')
54
+
const [__, intent, intentType] = urlp.pathname.split('/')
55
55
56
56
// On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
57
57
// intent check. On web, we have to check the first part of the path since we have an actual hostname
+11
-11
src/lib/strings/embed-player.ts
+11
-11
src/lib/strings/embed-player.ts
···
105
105
urlp.hostname === 'm.youtube.com' ||
106
106
urlp.hostname === 'music.youtube.com'
107
107
) {
108
-
const [_, page, shortOrLiveVideoId] = urlp.pathname.split('/')
108
+
const [__, page, shortOrLiveVideoId] = urlp.pathname.split('/')
109
109
110
110
const isShorts = page === 'shorts'
111
111
const isLive = page === 'live'
···
137
137
window.location.hostname
138
138
: 'localhost'
139
139
140
-
const [_, channelOrVideo, clipOrId, id] = urlp.pathname.split('/')
140
+
const [__, channelOrVideo, clipOrId, id] = urlp.pathname.split('/')
141
141
142
142
if (channelOrVideo === 'videos') {
143
143
return {
···
162
162
163
163
// spotify
164
164
if (urlp.hostname === 'open.spotify.com') {
165
-
const [_, typeOrLocale, idOrType, id] = urlp.pathname.split('/')
165
+
const [__, typeOrLocale, idOrType, id] = urlp.pathname.split('/')
166
166
167
167
if (idOrType) {
168
168
if (typeOrLocale === 'playlist' || idOrType === 'playlist') {
···
210
210
urlp.hostname === 'soundcloud.com' ||
211
211
urlp.hostname === 'www.soundcloud.com'
212
212
) {
213
-
const [_, user, trackOrSets, set] = urlp.pathname.split('/')
213
+
const [__, user, trackOrSets, set] = urlp.pathname.split('/')
214
214
215
215
if (user && trackOrSets) {
216
216
if (trackOrSets === 'sets' && set) {
···
270
270
}
271
271
272
272
if (urlp.hostname === 'vimeo.com' || urlp.hostname === 'www.vimeo.com') {
273
-
const [_, videoId] = urlp.pathname.split('/')
273
+
const [__, videoId] = urlp.pathname.split('/')
274
274
if (videoId) {
275
275
return {
276
276
type: 'vimeo_video',
···
281
281
}
282
282
283
283
if (urlp.hostname === 'giphy.com' || urlp.hostname === 'www.giphy.com') {
284
-
const [_, gifs, nameAndId] = urlp.pathname.split('/')
284
+
const [__, gifs, nameAndId] = urlp.pathname.split('/')
285
285
286
286
/*
287
287
* nameAndId is a string that consists of the name (dash separated) and the id of the gif (the last part of the name)
···
309
309
// These can include (presumably) a tracking id in the path name, so we have to check for that as well
310
310
if (giphyRegex.test(urlp.hostname)) {
311
311
// We can link directly to the gif, if its a proper link
312
-
const [_, media, trackingOrId, idOrFilename, filename] =
312
+
const [__, media, trackingOrId, idOrFilename, filename] =
313
313
urlp.pathname.split('/')
314
314
315
315
if (media === 'media') {
···
338
338
// Finally, we should see if it is a link to i.giphy.com. These links don't necessarily end in .gif but can also
339
339
// be .webp
340
340
if (urlp.hostname === 'i.giphy.com' || urlp.hostname === 'www.i.giphy.com') {
341
-
const [_, mediaOrFilename, filename] = urlp.pathname.split('/')
341
+
const [__, mediaOrFilename, filename] = urlp.pathname.split('/')
342
342
343
343
if (mediaOrFilename === 'media' && filename) {
344
344
const gifId = filename.split('.')[0]
···
389
389
const path_components = urlp.pathname.slice(1, i + 1).split('/')
390
390
if (path_components.length === 4) {
391
391
// discard username - it's not relevant
392
-
const [photos, _, albums, id] = path_components
392
+
const [photos, __, albums, id] = path_components
393
393
if (photos === 'photos' && albums === 'albums') {
394
394
// this at least has the shape of a valid photo-album URL!
395
395
return {
···
417
417
// link shortened flickr path
418
418
if (urlp.hostname === 'flic.kr') {
419
419
const b58alph = '123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ'
420
-
let [_, type, idBase58Enc] = urlp.pathname.split('/')
420
+
let [__, type, idBase58Enc] = urlp.pathname.split('/')
421
421
let id = 0n
422
422
for (const char of idBase58Enc) {
423
423
const nextIdx = b58alph.indexOf(char)
···
528
528
return {success: false}
529
529
}
530
530
531
-
let [_, id, filename] = urlp.pathname.split('/')
531
+
let [__, id, filename] = urlp.pathname.split('/')
532
532
533
533
if (!id || !filename) {
534
534
return {success: false}
-17
src/lib/strings/helpers.ts
-17
src/lib/strings/helpers.ts
···
62
62
}, [splitter, maxCount, text])
63
63
}
64
64
65
-
// https://stackoverflow.com/a/52171480
66
-
export function toHashCode(str: string, seed = 0): number {
67
-
let h1 = 0xdeadbeef ^ seed,
68
-
h2 = 0x41c6ce57 ^ seed
69
-
for (let i = 0, ch; i < str.length; i++) {
70
-
ch = str.charCodeAt(i)
71
-
h1 = Math.imul(h1 ^ ch, 2654435761)
72
-
h2 = Math.imul(h2 ^ ch, 1597334677)
73
-
}
74
-
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507)
75
-
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909)
76
-
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507)
77
-
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909)
78
-
79
-
return 4294967296 * (2097151 & h2) + (h1 >>> 0)
80
-
}
81
-
82
65
export function countLines(str: string | undefined): number {
83
66
if (!str) return 0
84
67
return str.match(/\n/g)?.length ?? 0
+1
-1
src/lib/strings/starter-pack.ts
+1
-1
src/lib/strings/starter-pack.ts
+10
-5
src/logger/metrics.ts
+10
-5
src/logger/metrics.ts
···
175
175
'feed:suggestion:press': {
176
176
feedUrl: string
177
177
}
178
-
'discover:showMore': {
178
+
'feed:showMore': {
179
+
feed: string
179
180
feedContext: string
180
181
}
181
-
'discover:showLess': {
182
+
'feed:showLess': {
183
+
feed: string
182
184
feedContext: string
183
185
}
184
-
'discover:clickthrough': {
186
+
'feed:clickthrough': {
187
+
feed: string
185
188
count: number
186
189
}
187
-
'discover:engaged': {
190
+
'feed:engaged': {
191
+
feed: string
188
192
count: number
189
193
}
190
-
'discover:seen': {
194
+
'feed:seen': {
195
+
feed: string
191
196
count: number
192
197
}
193
198
-2
src/screens/Onboarding/StepFinished.tsx
-2
src/screens/Onboarding/StepFinished.tsx
···
69
69
import * as bsky from '#/types/bsky'
70
70
71
71
export function StepFinished() {
72
-
const {_} = useLingui()
73
72
const {state, dispatch} = useContext(Context)
74
73
const onboardDispatch = useOnboardingDispatch()
75
74
const [saving, setSaving] = useState(false)
···
495
494
496
495
function Dot({active}: {active: boolean}) {
497
496
const t = useTheme()
498
-
const {_} = useLingui()
499
497
500
498
return (
501
499
<View
-1
src/screens/Search/Shell.tsx
-1
src/screens/Search/Shell.tsx
-1
src/screens/Search/modules/ExploreTrendingVideos.tsx
-1
src/screens/Search/modules/ExploreTrendingVideos.tsx
+26
-12
src/state/feed-feedback.tsx
+26
-12
src/state/feed-feedback.tsx
···
12
12
13
13
import {PROD_FEEDS, STAGING_FEEDS} from '#/lib/constants'
14
14
import {isNetworkError} from '#/lib/hooks/useCleanError'
15
-
import {logEvent} from '#/lib/statsig/statsig'
16
15
import {Logger} from '#/logger'
17
16
import {
18
17
type FeedSourceFeedInfo,
···
90
89
const aggregatedStats = useRef<AggregatedStats | null>(null)
91
90
const throttledFlushAggregatedStats = useMemo(
92
91
() =>
93
-
throttle(() => flushToStatsig(aggregatedStats.current), 45e3, {
94
-
leading: true, // The outer call is already throttled somewhat.
95
-
trailing: true,
96
-
}),
97
-
[],
92
+
throttle(
93
+
() =>
94
+
flushToStatsig(
95
+
aggregatedStats.current,
96
+
feed?.feedDescriptor ?? 'unknown',
97
+
),
98
+
45e3,
99
+
{
100
+
leading: true, // The outer call is already throttled somewhat.
101
+
trailing: true,
102
+
},
103
+
),
104
+
[feed?.feedDescriptor],
98
105
)
99
106
100
107
const sendToFeedNoDelay = useCallback(() => {
···
135
142
sendOrAggregateInteractionsForStats(
136
143
aggregatedStats.current,
137
144
interactionsToSend,
145
+
feed?.feedDescriptor ?? 'unknown',
138
146
)
139
147
throttledFlushAggregatedStats()
140
148
logger.debug('flushed')
···
271
279
function sendOrAggregateInteractionsForStats(
272
280
stats: AggregatedStats,
273
281
interactions: AppBskyFeedDefs.Interaction[],
282
+
feed: string,
274
283
) {
275
284
for (let interaction of interactions) {
276
285
switch (interaction.event) {
277
286
// Pressing "Show more" / "Show less" is relatively uncommon so we won't aggregate them.
278
287
// This lets us send the feed context together with them.
279
288
case 'app.bsky.feed.defs#requestLess': {
280
-
logEvent('discover:showLess', {
289
+
logger.metric('feed:showLess', {
290
+
feed,
281
291
feedContext: interaction.feedContext ?? '',
282
292
})
283
293
break
284
294
}
285
295
case 'app.bsky.feed.defs#requestMore': {
286
-
logEvent('discover:showMore', {
296
+
logger.metric('feed:showMore', {
297
+
feed,
287
298
feedContext: interaction.feedContext ?? '',
288
299
})
289
300
break
···
313
324
}
314
325
}
315
326
316
-
function flushToStatsig(stats: AggregatedStats | null) {
327
+
function flushToStatsig(stats: AggregatedStats | null, feedDescriptor: string) {
317
328
if (stats === null) {
318
329
return
319
330
}
320
331
321
332
if (stats.clickthroughCount > 0) {
322
-
logEvent('discover:clickthrough', {
333
+
logger.metric('feed:clickthrough', {
323
334
count: stats.clickthroughCount,
335
+
feed: feedDescriptor,
324
336
})
325
337
stats.clickthroughCount = 0
326
338
}
327
339
328
340
if (stats.engagedCount > 0) {
329
-
logEvent('discover:engaged', {
341
+
logger.metric('feed:engaged', {
330
342
count: stats.engagedCount,
343
+
feed: feedDescriptor,
331
344
})
332
345
stats.engagedCount = 0
333
346
}
334
347
335
348
if (stats.seenCount > 0) {
336
-
logEvent('discover:seen', {
349
+
logger.metric('feed:seen', {
337
350
count: stats.seenCount,
351
+
feed: feedDescriptor,
338
352
})
339
353
stats.seenCount = 0
340
354
}
+1
-1
src/state/persisted/schema.ts
+1
-1
src/state/persisted/schema.ts
···
71
71
contentLanguages: z.array(z.string()),
72
72
/**
73
73
* The language(s) the user is currently posting in, configured within the
74
-
* composer. Multiple languages are psearate by commas.
74
+
* composer. Multiple languages are separated by commas.
75
75
*
76
76
* BCP-47 2-letter language code without region.
77
77
*/
+4
src/state/preferences/languages.tsx
+4
src/state/preferences/languages.tsx
···
156
156
return postLanguage.split(',').filter(Boolean)
157
157
}
158
158
159
+
export function fromPostLanguages(languages: string[]): string {
160
+
return languages.filter(Boolean).join(',')
161
+
}
162
+
159
163
export function hasPostLanguage(postLanguage: string, code2: string): boolean {
160
164
return toPostLanguages(postLanguage).includes(code2)
161
165
}
+5
-5
src/state/queries/post-feed.ts
+5
-5
src/state/queries/post-feed.ts
···
492
492
}
493
493
}
494
494
} else if (feedDesc.startsWith('author')) {
495
-
const [_, actor, filter] = feedDesc.split('|')
495
+
const [__, actor, filter] = feedDesc.split('|')
496
496
return new AuthorFeedAPI({agent, feedParams: {actor, filter}})
497
497
} else if (feedDesc.startsWith('likes')) {
498
-
const [_, actor] = feedDesc.split('|')
498
+
const [__, actor] = feedDesc.split('|')
499
499
return new LikesFeedAPI({agent, feedParams: {actor}})
500
500
} else if (feedDesc.startsWith('feedgen')) {
501
-
const [_, feed] = feedDesc.split('|')
501
+
const [__, feed] = feedDesc.split('|')
502
502
return new CustomFeedAPI({
503
503
agent,
504
504
feedParams: {feed},
505
505
userInterests,
506
506
})
507
507
} else if (feedDesc.startsWith('list')) {
508
-
const [_, list] = feedDesc.split('|')
508
+
const [__, list] = feedDesc.split('|')
509
509
return new ListFeedAPI({agent, feedParams: {list}})
510
510
} else if (feedDesc.startsWith('posts')) {
511
-
const [_, uriList] = feedDesc.split('|')
511
+
const [__, uriList] = feedDesc.split('|')
512
512
return new PostListFeedAPI({agent, feedParams: {uris: uriList.split(',')}})
513
513
} else if (feedDesc === 'demo') {
514
514
return new DemoFeedAPI({agent})
+1
-1
src/state/queries/trending/useGetSuggestedUsersQuery.ts
+1
-1
src/state/queries/trending/useGetSuggestedUsersQuery.ts
-8
src/state/session/agent.ts
-8
src/state/session/agent.ts
···
321
321
322
322
// Now the agent is ready.
323
323
const account = agentToSessionAccountOrThrow(this)
324
-
let lastSession = this.sessionManager.session
325
324
this.persistSessionHandler = event => {
326
-
if (this.sessionManager.session) {
327
-
lastSession = this.sessionManager.session
328
-
} else if (event === 'network-error') {
329
-
// Put it back, we'll try again later.
330
-
this.sessionManager.session = lastSession
331
-
}
332
-
333
325
onSessionChange(this, account.did, event)
334
326
if (event !== 'create' && event !== 'update') {
335
327
addSessionErrorLog(account.did, event)
+55
-6
src/view/com/composer/Composer.tsx
+55
-6
src/view/com/composer/Composer.tsx
···
87
87
import {useModalControls} from '#/state/modals'
88
88
import {useRequireAltTextEnabled} from '#/state/preferences'
89
89
import {
90
+
fromPostLanguages,
90
91
toPostLanguages,
91
92
useLanguagePrefs,
92
93
useLanguagePrefsApi,
···
197
198
const [publishingStage, setPublishingStage] = useState('')
198
199
const [error, setError] = useState('')
199
200
201
+
/**
202
+
* A temporary local reference to a language suggestion that the user has
203
+
* accepted. This overrides the global post language preference, but is not
204
+
* stored permanently.
205
+
*/
206
+
const [acceptedLanguageSuggestion, setAcceptedLanguageSuggestion] = useState<
207
+
string | null
208
+
>(null)
209
+
210
+
/**
211
+
* The language(s) of the post being replied to.
212
+
*/
213
+
const [replyToLanguages, setReplyToLanguages] = useState<string[]>(
214
+
replyTo?.langs || [],
215
+
)
216
+
217
+
/**
218
+
* The currently selected languages of the post. Prefer local temporary
219
+
* language suggestion over global lang prefs, if available.
220
+
*/
221
+
const currentLanguages = useMemo(
222
+
() =>
223
+
acceptedLanguageSuggestion
224
+
? [acceptedLanguageSuggestion]
225
+
: toPostLanguages(langPrefs.postLanguage),
226
+
[acceptedLanguageSuggestion, langPrefs.postLanguage],
227
+
)
228
+
229
+
/**
230
+
* When the user selects a language from the composer language selector,
231
+
* clear any temporary language suggestions they may have selected
232
+
* previously, and any we might try to suggest to them.
233
+
*/
234
+
const onSelectLanguage = () => {
235
+
setAcceptedLanguageSuggestion(null)
236
+
setReplyToLanguages([])
237
+
}
238
+
200
239
const [composerState, composerDispatch] = useReducer(
201
240
composerReducer,
202
241
{
···
414
453
thread,
415
454
replyTo: replyTo?.uri,
416
455
onStateChange: setPublishingStage,
417
-
langs: toPostLanguages(langPrefs.postLanguage),
456
+
langs: currentLanguages,
418
457
})
419
458
).uris[0]
420
459
···
490
529
isPartOfThread: thread.posts.length > 1,
491
530
hasLink: !!post.embed.link,
492
531
hasQuote: !!post.embed.quote,
493
-
langs: langPrefs.postLanguage,
532
+
langs: fromPostLanguages(currentLanguages),
494
533
logContext: 'Composer',
495
534
})
496
535
index++
···
557
596
thread,
558
597
canPost,
559
598
isPublishing,
560
-
langPrefs.postLanguage,
599
+
currentLanguages,
561
600
onClose,
562
601
onPost,
563
602
onPostSuccess,
···
654
693
<>
655
694
<SuggestedLanguage
656
695
text={activePost.richtext.text}
657
-
// NOTE(@elijaharita): currently just choosing the first language if any exists
658
-
replyToLanguage={replyTo?.langs?.[0]}
696
+
replyToLanguages={replyToLanguages}
697
+
currentLanguages={currentLanguages}
698
+
onAcceptSuggestedLanguage={setAcceptedLanguageSuggestion}
659
699
/>
660
700
<ComposerPills
661
701
isReply={!!replyTo}
···
678
718
type: 'add_post',
679
719
})
680
720
}}
721
+
currentLanguages={currentLanguages}
722
+
onSelectLanguage={onSelectLanguage}
681
723
/>
682
724
</>
683
725
)
···
1314
1356
onEmojiButtonPress,
1315
1357
onSelectVideo,
1316
1358
onAddPost,
1359
+
currentLanguages,
1360
+
onSelectLanguage,
1317
1361
}: {
1318
1362
post: PostDraft
1319
1363
dispatch: (action: PostAction) => void
···
1322
1366
onError: (error: string) => void
1323
1367
onSelectVideo: (postId: string, asset: ImagePickerAsset) => void
1324
1368
onAddPost: () => void
1369
+
currentLanguages: string[]
1370
+
onSelectLanguage?: (language: string) => void
1325
1371
}) {
1326
1372
const t = useTheme()
1327
1373
const {_} = useLingui()
···
1475
1521
<PlusIcon size="lg" />
1476
1522
</Button>
1477
1523
)}
1478
-
<PostLanguageSelect />
1524
+
<PostLanguageSelect
1525
+
currentLanguages={currentLanguages}
1526
+
onSelectLanguage={onSelectLanguage}
1527
+
/>
1479
1528
<CharProgress
1480
1529
count={post.shortenedGraphemeLength}
1481
1530
style={{width: 65}}
-1
src/view/com/composer/photos/EditImageDialog.web.tsx
-1
src/view/com/composer/photos/EditImageDialog.web.tsx
+35
-9
src/view/com/composer/select-language/PostLanguageSelect.tsx
+35
-9
src/view/com/composer/select-language/PostLanguageSelect.tsx
···
17
17
import {Text} from '#/components/Typography'
18
18
import {PostLanguageSelectDialog} from './PostLanguageSelectDialog'
19
19
20
-
export function PostLanguageSelect() {
20
+
export function PostLanguageSelect({
21
+
currentLanguages: currentLanguagesProp,
22
+
onSelectLanguage,
23
+
}: {
24
+
currentLanguages?: string[]
25
+
onSelectLanguage?: (language: string) => void
26
+
}) {
21
27
const {_} = useLingui()
22
28
const langPrefs = useLanguagePrefs()
23
29
const setLangPrefs = useLanguagePrefsApi()
···
26
32
const dedupedHistory = Array.from(
27
33
new Set([...langPrefs.postLanguageHistory, langPrefs.postLanguage]),
28
34
)
35
+
36
+
const currentLanguages =
37
+
currentLanguagesProp ?? toPostLanguages(langPrefs.postLanguage)
29
38
30
39
if (
31
40
dedupedHistory.length === 1 &&
···
34
43
return (
35
44
<>
36
45
<LanguageBtn onPress={languageDialogControl.open} />
37
-
<PostLanguageSelectDialog control={languageDialogControl} />
46
+
<PostLanguageSelectDialog
47
+
control={languageDialogControl}
48
+
currentLanguages={currentLanguages}
49
+
/>
38
50
</>
39
51
)
40
52
}
···
43
55
<>
44
56
<Menu.Root>
45
57
<Menu.Trigger label={_(msg`Select post language`)}>
46
-
{({props}) => <LanguageBtn {...props} />}
58
+
{({props}) => (
59
+
<LanguageBtn currentLanguages={currentLanguages} {...props} />
60
+
)}
47
61
</Menu.Trigger>
48
62
<Menu.Outer>
49
63
<Menu.Group>
···
56
70
<Menu.Item
57
71
key={historyItem}
58
72
label={_(msg`Select ${langName}`)}
59
-
onPress={() => setLangPrefs.setPostLanguage(historyItem)}>
73
+
onPress={() => {
74
+
setLangPrefs.setPostLanguage(historyItem)
75
+
onSelectLanguage?.(historyItem)
76
+
}}>
60
77
<Menu.ItemText>{langName}</Menu.ItemText>
61
78
<Menu.ItemRadio
62
-
selected={historyItem === langPrefs.postLanguage}
79
+
selected={currentLanguages.includes(historyItem)}
63
80
/>
64
81
</Menu.Item>
65
82
)
···
77
94
</Menu.Outer>
78
95
</Menu.Root>
79
96
80
-
<PostLanguageSelectDialog control={languageDialogControl} />
97
+
<PostLanguageSelectDialog
98
+
control={languageDialogControl}
99
+
currentLanguages={currentLanguages}
100
+
onSelectLanguage={onSelectLanguage}
101
+
/>
81
102
</>
82
103
)
83
104
}
84
105
85
-
function LanguageBtn(props: Omit<ButtonProps, 'label' | 'children'>) {
106
+
function LanguageBtn(
107
+
props: Omit<ButtonProps, 'label' | 'children'> & {
108
+
currentLanguages?: string[]
109
+
},
110
+
) {
86
111
const {_} = useLingui()
87
112
const langPrefs = useLanguagePrefs()
88
113
const t = useTheme()
89
114
90
115
const postLanguagesPref = toPostLanguages(langPrefs.postLanguage)
116
+
const currentLanguages = props.currentLanguages ?? postLanguagesPref
91
117
92
118
return (
93
119
<Button
···
106
132
{({pressed, hovered}) => {
107
133
const color =
108
134
pressed || hovered ? t.palette.primary_300 : t.palette.primary_500
109
-
if (postLanguagesPref.length > 0) {
135
+
if (currentLanguages.length > 0) {
110
136
return (
111
137
<Text
112
138
style={[
···
117
143
{maxWidth: 100},
118
144
]}
119
145
numberOfLines={1}>
120
-
{postLanguagesPref
146
+
{currentLanguages
121
147
.map(lang => codeToLanguageName(lang, langPrefs.appLanguage))
122
148
.join(', ')}
123
149
</Text>
+25
-3
src/view/com/composer/select-language/PostLanguageSelectDialog.tsx
+25
-3
src/view/com/composer/select-language/PostLanguageSelectDialog.tsx
···
8
8
import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages'
9
9
import {isNative, isWeb} from '#/platform/detection'
10
10
import {
11
+
toPostLanguages,
11
12
useLanguagePrefs,
12
13
useLanguagePrefsApi,
13
14
} from '#/state/preferences/languages'
···
23
24
24
25
export function PostLanguageSelectDialog({
25
26
control,
27
+
/**
28
+
* Optionally can be passed to show different values than what is saved in
29
+
* langPrefs.
30
+
*/
31
+
currentLanguages,
32
+
onSelectLanguage,
26
33
}: {
27
34
control: Dialog.DialogControlProps
35
+
currentLanguages?: string[]
36
+
onSelectLanguage?: (language: string) => void
28
37
}) {
29
38
const {height} = useWindowDimensions()
30
39
const insets = useSafeAreaInsets()
···
40
49
nativeOptions={{minHeight: height - insets.top}}>
41
50
<Dialog.Handle />
42
51
<ErrorBoundary renderError={renderErrorBoundary}>
43
-
<DialogInner />
52
+
<DialogInner
53
+
currentLanguages={currentLanguages}
54
+
onSelectLanguage={onSelectLanguage}
55
+
/>
44
56
</ErrorBoundary>
45
57
</Dialog.Outer>
46
58
)
47
59
}
48
60
49
-
export function DialogInner() {
61
+
export function DialogInner({
62
+
currentLanguages,
63
+
onSelectLanguage,
64
+
}: {
65
+
currentLanguages?: string[]
66
+
onSelectLanguage?: (language: string) => void
67
+
}) {
50
68
const control = Dialog.useDialogContext()
51
69
const [headerHeight, setHeaderHeight] = useState(0)
52
70
···
63
81
}, [])
64
82
65
83
const langPrefs = useLanguagePrefs()
84
+
const postLanguagesPref =
85
+
currentLanguages ?? toPostLanguages(langPrefs.postLanguage)
86
+
66
87
const [checkedLanguagesCode2, setCheckedLanguagesCode2] = useState<string[]>(
67
-
langPrefs.postLanguage.split(',') || [langPrefs.primaryLanguage],
88
+
postLanguagesPref || [langPrefs.primaryLanguage],
68
89
)
69
90
const [search, setSearch] = useState('')
70
91
···
79
100
langsString = langPrefs.primaryLanguage
80
101
}
81
102
setLangPrefs.setPostLanguage(langsString)
103
+
onSelectLanguage?.(langsString)
82
104
})
83
105
}
84
106
+121
-44
src/view/com/composer/select-language/SuggestedLanguage.tsx
+121
-44
src/view/com/composer/select-language/SuggestedLanguage.tsx
···
1
1
import {useEffect, useState} from 'react'
2
-
import {View} from 'react-native'
2
+
import {Text as RNText, View} from 'react-native'
3
3
import {parseLanguage} from '@atproto/api'
4
4
import {msg, Trans} from '@lingui/macro'
5
5
import {useLingui} from '@lingui/react'
6
6
import lande from 'lande'
7
7
8
8
import {code3ToCode2Strict, codeToLanguageName} from '#/locale/helpers'
9
-
import {
10
-
toPostLanguages,
11
-
useLanguagePrefs,
12
-
useLanguagePrefsApi,
13
-
} from '#/state/preferences/languages'
9
+
import {useLanguagePrefs} from '#/state/preferences/languages'
14
10
import {atoms as a, useTheme} from '#/alf'
15
11
import {Button, ButtonText} from '#/components/Button'
16
12
import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe'
···
22
18
23
19
export function SuggestedLanguage({
24
20
text,
25
-
replyToLanguage: replyToLanguageProp,
21
+
replyToLanguages: replyToLanguagesProp,
22
+
currentLanguages,
23
+
onAcceptSuggestedLanguage,
26
24
}: {
27
25
text: string
28
-
replyToLanguage?: string
26
+
/**
27
+
* All languages associated with the post being replied to.
28
+
*/
29
+
replyToLanguages: string[]
30
+
/**
31
+
* All languages currently selected for the post being composed.
32
+
*/
33
+
currentLanguages: string[]
34
+
/**
35
+
* Called when the user accepts a suggested language. We only pass a single
36
+
* language here. If the post being replied to has multiple languages, we
37
+
* only suggest the first one.
38
+
*/
39
+
onAcceptSuggestedLanguage: (language: string | null) => void
29
40
}) {
30
-
const replyToLanguage = cleanUpLanguage(replyToLanguageProp)
41
+
const langPrefs = useLanguagePrefs()
42
+
const replyToLanguages = replyToLanguagesProp
43
+
.map(lang => cleanUpLanguage(lang))
44
+
.filter(Boolean) as string[]
45
+
const [hasInteracted, setHasInteracted] = useState(false)
31
46
const [suggestedLanguage, setSuggestedLanguage] = useState<
32
47
string | undefined
33
-
>(text.length === 0 ? replyToLanguage : undefined)
34
-
const langPrefs = useLanguagePrefs()
35
-
const setLangPrefs = useLanguagePrefsApi()
36
-
const t = useTheme()
37
-
const {_} = useLingui()
48
+
>(undefined)
38
49
39
50
useEffect(() => {
40
-
// For replies, suggest the language of the post being replied to if no text
41
-
// has been typed yet
42
-
if (replyToLanguage && text.length === 0) {
43
-
setSuggestedLanguage(replyToLanguage)
44
-
return
51
+
if (text.length > 0 && !hasInteracted) {
52
+
setHasInteracted(true)
45
53
}
54
+
}, [text, hasInteracted])
46
55
56
+
useEffect(() => {
47
57
const textTrimmed = text.trim()
48
58
49
59
// Don't run the language model on small posts, the results are likely
···
58
68
})
59
69
60
70
return () => cancelIdle(idle)
61
-
}, [text, replyToLanguage])
71
+
}, [text])
72
+
73
+
/*
74
+
* We've detected a language, and the user hasn't already selected it.
75
+
*/
76
+
const hasLanguageSuggestion =
77
+
suggestedLanguage && !currentLanguages.includes(suggestedLanguage)
78
+
/*
79
+
* We have not detected a different language, and the user is not already
80
+
* using or has not already selected one of the languages of the post they
81
+
* are replying to.
82
+
*/
83
+
const hasSuggestedReplyLanguage =
84
+
!hasInteracted &&
85
+
!suggestedLanguage &&
86
+
replyToLanguages.length &&
87
+
!replyToLanguages.some(l => currentLanguages.includes(l))
62
88
63
-
if (
64
-
suggestedLanguage &&
65
-
!toPostLanguages(langPrefs.postLanguage).includes(suggestedLanguage)
66
-
) {
89
+
if (hasLanguageSuggestion) {
67
90
const suggestedLanguageName = codeToLanguageName(
68
91
suggestedLanguage,
69
92
langPrefs.appLanguage,
70
93
)
71
94
72
95
return (
96
+
<LanguageSuggestionButton
97
+
label={
98
+
<RNText>
99
+
<Trans>
100
+
Are you writing in{' '}
101
+
<Text style={[a.font_bold]}>{suggestedLanguageName}</Text>?
102
+
</Trans>
103
+
</RNText>
104
+
}
105
+
value={suggestedLanguage}
106
+
onAccept={onAcceptSuggestedLanguage}
107
+
/>
108
+
)
109
+
} else if (hasSuggestedReplyLanguage) {
110
+
const suggestedLanguageName = codeToLanguageName(
111
+
replyToLanguages[0],
112
+
langPrefs.appLanguage,
113
+
)
114
+
115
+
return (
116
+
<LanguageSuggestionButton
117
+
label={
118
+
<RNText>
119
+
<Trans>
120
+
The post you're replying to was marked as being written in{' '}
121
+
{suggestedLanguageName} by its author. Would you like to reply in{' '}
122
+
<Text style={[a.font_bold]}>{suggestedLanguageName}</Text>?
123
+
</Trans>
124
+
</RNText>
125
+
}
126
+
value={replyToLanguages[0]}
127
+
onAccept={onAcceptSuggestedLanguage}
128
+
/>
129
+
)
130
+
} else {
131
+
return null
132
+
}
133
+
}
134
+
135
+
function LanguageSuggestionButton({
136
+
label,
137
+
value,
138
+
onAccept,
139
+
}: {
140
+
label: React.ReactNode
141
+
value: string
142
+
onAccept: (language: string | null) => void
143
+
}) {
144
+
const t = useTheme()
145
+
const {_} = useLingui()
146
+
147
+
return (
148
+
<View style={[a.px_lg, a.py_sm]}>
73
149
<View
74
150
style={[
75
-
t.atoms.border_contrast_low,
76
-
a.gap_sm,
151
+
a.gap_md,
77
152
a.border,
78
153
a.flex_row,
79
154
a.align_center,
80
155
a.rounded_sm,
81
-
a.px_lg,
82
-
a.py_md,
83
-
a.mx_md,
84
-
a.my_sm,
156
+
a.p_md,
157
+
a.pl_lg,
85
158
t.atoms.bg,
159
+
t.atoms.border_contrast_low,
86
160
]}>
87
161
<EarthIcon />
88
-
<Text style={[a.flex_1]}>
89
-
<Trans>
90
-
Are you writing in{' '}
91
-
<Text style={[a.font_bold]}>{suggestedLanguageName}</Text>?
92
-
</Trans>
93
-
</Text>
162
+
<View style={[a.flex_1]}>
163
+
<Text
164
+
style={[
165
+
a.flex_1,
166
+
a.leading_snug,
167
+
{
168
+
maxWidth: 400,
169
+
},
170
+
]}>
171
+
{label}
172
+
</Text>
173
+
</View>
94
174
95
175
<Button
96
-
color="secondary"
97
176
size="small"
98
-
variant="solid"
99
-
onPress={() => setLangPrefs.setPostLanguage(suggestedLanguage)}
100
-
label={_(msg`Change post language to ${suggestedLanguageName}`)}>
177
+
color="secondary"
178
+
onPress={() => onAccept(value)}
179
+
label={_(msg`Accept this language suggestion`)}>
101
180
<ButtonText>
102
181
<Trans>Yes</Trans>
103
182
</ButtonText>
104
183
</Button>
105
184
</View>
106
-
)
107
-
} else {
108
-
return null
109
-
}
185
+
</View>
186
+
)
110
187
}
111
188
112
189
/**
+1
-1
src/view/com/composer/text-input/web/TagDecorator.ts
+1
-1
src/view/com/composer/text-input/web/TagDecorator.ts
+1
-1
src/view/com/posts/PostFeedErrorMessage.tsx
+1
-1
src/view/com/posts/PostFeedErrorMessage.tsx
···
128
128
})[knownError],
129
129
[_l, knownError],
130
130
)
131
-
const [_, uri] = feedDesc.split('|')
131
+
const [__, uri] = feedDesc.split('|')
132
132
const [ownerDid] = safeParseFeedgenUri(uri)
133
133
const removePromptControl = Prompt.usePromptControl()
134
134
const {mutateAsync: removeFeed} = useRemoveFeedMutation()
-2
src/view/screens/ModerationMutedAccounts.tsx
-2
src/view/screens/ModerationMutedAccounts.tsx
···
2
2
import {type StyleProp, View, type ViewStyle} from 'react-native'
3
3
import {type AppBskyActorDefs as ActorDefs} from '@atproto/api'
4
4
import {Trans} from '@lingui/macro'
5
-
import {useLingui} from '@lingui/react'
6
5
import {useFocusEffect} from '@react-navigation/native'
7
6
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
8
7
···
27
26
export function ModerationMutedAccounts({}: Props) {
28
27
const t = useTheme()
29
28
const moderationOpts = useModerationOpts()
30
-
const {_} = useLingui()
31
29
const setMinimalShellMode = useSetMinimalShellMode()
32
30
33
31
const [isPTRing, setIsPTRing] = useState(false)
-2
src/view/screens/ProfileFeedLikedBy.tsx
-2
src/view/screens/ProfileFeedLikedBy.tsx
···
1
1
import {useCallback} from 'react'
2
2
import {Trans} from '@lingui/macro'
3
-
import {useLingui} from '@lingui/react'
4
3
import {useFocusEffect} from '@react-navigation/native'
5
4
6
5
import {
···
17
16
const setMinimalShellMode = useSetMinimalShellMode()
18
17
const {name, rkey} = route.params
19
18
const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey)
20
-
const {_} = useLingui()
21
19
22
20
useFocusEffect(
23
21
useCallback(() => {
+24
src/view/screens/Storybook/Buttons.tsx
+24
src/view/screens/Storybook/Buttons.tsx
···
43
43
</StackedButton>
44
44
</View>
45
45
46
+
<View style={[a.flex_row, a.gap_md, a.align_start, {maxWidth: 350}]}>
47
+
<StackedButton
48
+
label="stacked"
49
+
icon={Globe}
50
+
color="secondary"
51
+
style={[a.flex_1]}>
52
+
Bop it
53
+
</StackedButton>
54
+
<StackedButton
55
+
label="stacked"
56
+
icon={Globe}
57
+
color="negative_subtle"
58
+
style={[a.flex_1]}>
59
+
Twist it
60
+
</StackedButton>
61
+
<StackedButton
62
+
label="stacked"
63
+
icon={Globe}
64
+
color="primary"
65
+
style={[a.flex_1]}>
66
+
Pull it
67
+
</StackedButton>
68
+
</View>
69
+
46
70
{[
47
71
'primary',
48
72
'secondary',
+48
-12
yarn.lock
+48
-12
yarn.lock
···
77
77
tlds "^1.234.0"
78
78
zod "^3.23.8"
79
79
80
+
"@atproto/api@^0.17.0":
81
+
version "0.17.0"
82
+
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.17.0.tgz#1fe87ef703f8020dbe00bb5e5cc18622b8b91f4a"
83
+
integrity sha512-FNS9SW7/3kslAnJH7F4fO9/jPjXzC0NMD6u9NjJ/h4EnaIEpWHZQPkmD9Q2hvAwD6+Uo2boYZEPKkOa55Lr5Dg==
84
+
dependencies:
85
+
"@atproto/common-web" "^0.4.3"
86
+
"@atproto/lexicon" "^0.5.1"
87
+
"@atproto/syntax" "^0.4.1"
88
+
"@atproto/xrpc" "^0.7.5"
89
+
await-lock "^2.2.2"
90
+
multiformats "^9.9.0"
91
+
tlds "^1.234.0"
92
+
zod "^3.23.8"
93
+
80
94
"@atproto/aws@^0.2.28":
81
95
version "0.2.28"
82
96
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.28.tgz#17bd88a6276e323ebb094a3f01bd94b1173a29a4"
···
170
184
uint8arrays "3.0.0"
171
185
zod "^3.23.8"
172
186
187
+
"@atproto/common-web@^0.4.3":
188
+
version "0.4.3"
189
+
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.3.tgz#b4480220b5682db09da45f4ef906eb7619c838b5"
190
+
integrity sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==
191
+
dependencies:
192
+
graphemer "^1.4.0"
193
+
multiformats "^9.9.0"
194
+
uint8arrays "3.0.0"
195
+
zod "^3.23.8"
196
+
173
197
"@atproto/common@0.1.0":
174
198
version "0.1.0"
175
199
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210"
···
298
322
integrity sha512-3aAzEAy9EAPs3CxznzMhEcqDd7m3vz1eze/ya9/ThbB7yleqJIhz5GY2q76tCCwHPhn5qDDMhlA9kKV6fG23gA==
299
323
dependencies:
300
324
"@atproto/common-web" "^0.4.2"
325
+
"@atproto/syntax" "^0.4.1"
326
+
iso-datestring-validator "^2.2.2"
327
+
multiformats "^9.9.0"
328
+
zod "^3.23.8"
329
+
330
+
"@atproto/lexicon@^0.5.1":
331
+
version "0.5.1"
332
+
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.5.1.tgz#e9b7d5c70dc5a38518a8069cd80fea77ab526947"
333
+
integrity sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A==
334
+
dependencies:
335
+
"@atproto/common-web" "^0.4.3"
301
336
"@atproto/syntax" "^0.4.1"
302
337
iso-datestring-validator "^2.2.2"
303
338
multiformats "^9.9.0"
···
514
549
integrity sha512-sDi68+QE1XHegTaNAndlX41Gp827pouSzSs8CyAwhrqZdsJUxE3P7TMtrA0z+zAjvxVyvzscRc0TsN/fGUGrhw==
515
550
dependencies:
516
551
"@atproto/lexicon" "^0.5.0"
552
+
zod "^3.23.8"
553
+
554
+
"@atproto/xrpc@^0.7.5":
555
+
version "0.7.5"
556
+
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.5.tgz#40cef1a657b5f28af8ebec9e3dac5872e58e88ea"
557
+
integrity sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA==
558
+
dependencies:
559
+
"@atproto/lexicon" "^0.5.1"
517
560
zod "^3.23.8"
518
561
519
562
"@aws-crypto/crc32@3.0.0":
···
8596
8639
dependencies:
8597
8640
"@babel/helper-define-polyfill-provider" "^0.6.3"
8598
8641
8599
-
babel-plugin-react-compiler@^19.1.0-rc.1:
8600
-
version "19.1.0-rc.1"
8601
-
resolved "https://registry.yarnpkg.com/babel-plugin-react-compiler/-/babel-plugin-react-compiler-19.1.0-rc.1.tgz#99d131be61017e40abbaedd98321069bf8b7e54a"
8602
-
integrity sha512-M4fpG+Hfq5gWzsJeeMErdRokzg0fdJ8IAk+JDhfB/WLT+U3WwJWR8edphypJrk447/JEvYu6DBFwsTn10bMW4Q==
8603
-
dependencies:
8604
-
"@babel/types" "^7.26.0"
8605
-
8606
-
babel-plugin-react-compiler@^19.1.0-rc.2:
8642
+
babel-plugin-react-compiler@^19.1.0-rc.2, babel-plugin-react-compiler@^19.1.0-rc.3:
8607
8643
version "19.1.0-rc.3"
8608
8644
resolved "https://registry.yarnpkg.com/babel-plugin-react-compiler/-/babel-plugin-react-compiler-19.1.0-rc.3.tgz#45e5a282a2460b3701971e5eb8310a90a7919022"
8609
8645
integrity sha512-mjRn69WuTz4adL0bXGx8Rsyk1086zFJeKmes6aK0xPuK3aaXmDJdLHqwKKMrpm6KAI1MCoUK72d2VeqQbu8YIA==
···
10817
10853
dependencies:
10818
10854
"@typescript-eslint/utils" "^5.61.0"
10819
10855
10820
-
eslint-plugin-react-compiler@^19.1.0-rc.1:
10821
-
version "19.1.0-rc.1"
10822
-
resolved "https://registry.yarnpkg.com/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-19.1.0-rc.1.tgz#e974ba9541c9a4464d77723e0505b5742bc22e56"
10823
-
integrity sha512-3umw5eqZXapBl7aQGmvcjheKhUbsElb9jTETxRZg371e1LG4EPs/zCHt2JzP+wNcdaZWzjU/R730zPUJblY2zw==
10856
+
eslint-plugin-react-compiler@^19.1.0-rc.2:
10857
+
version "19.1.0-rc.2"
10858
+
resolved "https://registry.yarnpkg.com/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-19.1.0-rc.2.tgz#83343e7422e00fa61e729af8e8468f0ddec37925"
10859
+
integrity sha512-oKalwDGcD+RX9mf3NEO4zOoUMeLvjSvcbbEOpquzmzqEEM2MQdp7/FY/Hx9NzmUwFzH1W9SKTz5fihfMldpEYw==
10824
10860
dependencies:
10825
10861
"@babel/core" "^7.24.4"
10826
10862
"@babel/parser" "^7.24.4"