personal web client for Bluesky
typescript solidjs bluesky atcute
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: content translation ui

mary.my.id dafea098 0b2f9dc0

verified
+709 -86
+245
src/api/basa/languages.ts
··· 1 + export const googleLanguages = [ 2 + 'ab', 3 + 'ace', 4 + 'ach', 5 + 'aa', 6 + 'af', 7 + 'sq', 8 + 'alz', 9 + 'am', 10 + 'ar', 11 + 'hy', 12 + 'as', 13 + 'av', 14 + 'awa', 15 + 'ay', 16 + 'az', 17 + 'ban', 18 + 'bal', 19 + 'bm', 20 + 'bci', 21 + 'ba', 22 + 'eu', 23 + 'btx', 24 + 'bts', 25 + 'bbc', 26 + 'be', 27 + 'bem', 28 + 'bn', 29 + 'bew', 30 + 'bho', 31 + 'bik', 32 + 'bs', 33 + 'br', 34 + 'bg', 35 + 'bua', 36 + 'yue', 37 + 'ca', 38 + 'ceb', 39 + 'ch', 40 + 'ce', 41 + 'ny', 42 + 'zh-CN', 43 + 'zh-TW', 44 + 'chk', 45 + 'cv', 46 + 'co', 47 + 'crh', 48 + 'hr', 49 + 'cs', 50 + 'da', 51 + 'fa-AF', 52 + 'dv', 53 + 'din', 54 + 'doi', 55 + 'dov', 56 + 'nl', 57 + 'dyu', 58 + 'dz', 59 + 'en', 60 + 'eo', 61 + 'et', 62 + 'ee', 63 + 'fo', 64 + 'fj', 65 + 'tl', 66 + 'fi', 67 + 'fon', 68 + 'fr', 69 + 'fy', 70 + 'fur', 71 + 'ff', 72 + 'gaa', 73 + 'gl', 74 + 'ka', 75 + 'de', 76 + 'el', 77 + 'gn', 78 + 'gu', 79 + 'ht', 80 + 'cnh', 81 + 'ha', 82 + 'haw', 83 + 'iw', 84 + 'hil', 85 + 'hi', 86 + 'hmn', 87 + 'hu', 88 + 'hrx', 89 + 'iba', 90 + 'is', 91 + 'ig', 92 + 'ilo', 93 + 'id', 94 + 'ga', 95 + 'it', 96 + 'jam', 97 + 'ja', 98 + 'jw', 99 + 'kac', 100 + 'kl', 101 + 'kn', 102 + 'kr', 103 + 'pam', 104 + 'kk', 105 + 'kha', 106 + 'km', 107 + 'cgg', 108 + 'kg', 109 + 'rw', 110 + 'ktu', 111 + 'trp', 112 + 'kv', 113 + 'gom', 114 + 'ko', 115 + 'kri', 116 + 'ku', 117 + 'ckb', 118 + 'ky', 119 + 'lo', 120 + 'ltg', 121 + 'la', 122 + 'lv', 123 + 'lij', 124 + 'li', 125 + 'ln', 126 + 'lt', 127 + 'lmo', 128 + 'lg', 129 + 'luo', 130 + 'lb', 131 + 'mk', 132 + 'mad', 133 + 'mai', 134 + 'mak', 135 + 'mg', 136 + 'ms', 137 + 'ms-Arab', 138 + 'ml', 139 + 'mt', 140 + 'mam', 141 + 'gv', 142 + 'mi', 143 + 'mr', 144 + 'mh', 145 + 'mwr', 146 + 'mfe', 147 + 'chm', 148 + 'mni-Mtei', 149 + 'min', 150 + 'lus', 151 + 'mn', 152 + 'my', 153 + 'nhe', 154 + 'ndc-ZW', 155 + 'nr', 156 + 'new', 157 + 'ne', 158 + 'bm-Nkoo', 159 + 'no', 160 + 'nus', 161 + 'oc', 162 + 'or', 163 + 'om', 164 + 'os', 165 + 'pag', 166 + 'pap', 167 + 'ps', 168 + 'fa', 169 + 'pl', 170 + 'pt', 171 + 'pt-PT', 172 + 'pa', 173 + 'pa-Arab', 174 + 'qu', 175 + 'kek', 176 + 'rom', 177 + 'ro', 178 + 'rn', 179 + 'ru', 180 + 'se', 181 + 'sm', 182 + 'sg', 183 + 'sa', 184 + 'sat-Latn', 185 + 'gd', 186 + 'nso', 187 + 'sr', 188 + 'st', 189 + 'crs', 190 + 'shn', 191 + 'sn', 192 + 'scn', 193 + 'szl', 194 + 'sd', 195 + 'si', 196 + 'sk', 197 + 'sl', 198 + 'so', 199 + 'es', 200 + 'su', 201 + 'sus', 202 + 'sw', 203 + 'ss', 204 + 'sv', 205 + 'ty', 206 + 'tg', 207 + 'ber-Latn', 208 + 'ber', 209 + 'ta', 210 + 'tt', 211 + 'te', 212 + 'tet', 213 + 'th', 214 + 'bo', 215 + 'ti', 216 + 'tiv', 217 + 'tpi', 218 + 'to', 219 + 'ts', 220 + 'tn', 221 + 'tcy', 222 + 'tum', 223 + 'tr', 224 + 'tk', 225 + 'tyv', 226 + 'ak', 227 + 'udm', 228 + 'uk', 229 + 'ur', 230 + 'ug', 231 + 'uz', 232 + 've', 233 + 'vec', 234 + 'vi', 235 + 'war', 236 + 'cy', 237 + 'wo', 238 + 'xh', 239 + 'sah', 240 + 'yi', 241 + 'yo', 242 + 'yua', 243 + 'zap', 244 + 'zu', 245 + ];
+69
src/basa-env.d.ts
··· 1 + /* eslint-disable */ 2 + // This file is automatically generated, do not edit! 3 + import '@atcute/client/lexicons'; 4 + 5 + declare module '@atcute/client/lexicons' { 6 + /** Describes the translation proxy instance */ 7 + namespace XBasaDescribeServer { 8 + interface Params {} 9 + type Input = undefined; 10 + interface Output { 11 + engines: Engines; 12 + } 13 + interface Engine { 14 + [Brand.Type]?: 'x.basa.describeServer#engine'; 15 + /** Supported language codes */ 16 + languages: string[]; 17 + } 18 + interface Engines { 19 + [Brand.Type]?: 'x.basa.describeServer#engines'; 20 + deepl?: Engine; 21 + google?: Engine; 22 + } 23 + } 24 + 25 + /** Translates a given text into another language */ 26 + namespace XBasaTranslate { 27 + interface Params { 28 + /** Text needing translation */ 29 + text: string; 30 + /** Target language */ 31 + to: string; 32 + /** 33 + * Which translation service to use 34 + * @default "google" 35 + */ 36 + engine?: 'google' | 'deepl'; 37 + /** 38 + * Source language 39 + * @default "auto" 40 + */ 41 + from?: string; 42 + } 43 + type Input = undefined; 44 + interface Output { 45 + /** Translated text */ 46 + result: string; 47 + /** Deteced language from source text */ 48 + sourceLanguage?: string; 49 + /** Transliteration of source text */ 50 + sourceTransliteration?: string; 51 + /** Transliteration of translated text */ 52 + targetTransliteration?: string; 53 + } 54 + } 55 + 56 + interface Records {} 57 + 58 + interface Queries { 59 + 'x.basa.describeServer': { 60 + output: XBasaDescribeServer.Output; 61 + }; 62 + 'x.basa.translate': { 63 + params: XBasaTranslate.Params; 64 + output: XBasaTranslate.Output; 65 + }; 66 + } 67 + 68 + interface Procedures {} 69 + }
+25 -5
src/components/boxed.tsx
··· 2 2 3 3 import { openModal, useModalContext } from '~/globals/modals'; 4 4 5 + import { useFieldset } from './fieldset'; 5 6 import ChevronRightOutlinedIcon from './icons-central/chevron-right-outline'; 6 7 import * as Menu from './menu'; 7 8 ··· 114 115 115 116 export interface BoxedButtonItemProps { 116 117 label: string; 118 + icon?: Component<ComponentProps<'svg'>>; 117 119 description?: string; 118 120 blurb?: string; 119 121 variant?: 'default' | 'danger'; ··· 126 128 onClick={props.onClick} 127 129 class="flex justify-between gap-4 px-4 py-3 text-left hover:bg-contrast/sm active:bg-contrast/sm-pressed" 128 130 > 131 + {(() => { 132 + const Icon = props.icon; 133 + 134 + if (Icon) { 135 + return <Icon class="mt-px text-lg text-contrast-muted" />; 136 + } 137 + })()} 138 + 129 139 <div class="flex min-w-0 grow flex-col"> 130 140 <p class={buttonItemLabelProps(props)}>{props.label}</p> 131 141 <p class="text-pretty break-words text-de text-contrast-muted empty:hidden">{props.description}</p> ··· 133 143 134 144 <span class="flex min-w-0 gap-1"> 135 145 <span class="min-w-0 break-words text-de text-contrast-muted empty:hidden">{props.blurb}</span> 136 - <ChevronRightOutlinedIcon class="-mr-1.5 shrink-0 text-xl text-contrast-muted" /> 146 + <ChevronRightOutlinedIcon class="-mr-1.5 shrink-0 text-xl text-contrast-muted " /> 137 147 </span> 138 148 </button> 139 149 ); ··· 206 216 export interface BoxedSelectItemProps<T> { 207 217 label: string; 208 218 description?: string; 219 + disabled?: boolean; 209 220 value: T; 210 221 options: SelectItemOption<T>[]; 211 222 onChange: (next: T) => void; ··· 213 224 214 225 const BoxedSelectItem = <T extends string | number>(props: BoxedSelectItemProps<T>) => { 215 226 const onChange = props.onChange; 227 + 228 + const fieldset = useFieldset(); 216 229 217 230 const options = createMemo(() => props.options); 218 231 const selected = createMemo(() => { ··· 224 237 225 238 return ( 226 239 <button 240 + disabled={fieldset.disabled || !!props.disabled} 227 241 onClick={(ev) => { 228 242 const anchor = ev.currentTarget; 229 243 ··· 246 260 ); 247 261 }); 248 262 }} 249 - class="flex flex-col items-stretch px-4 py-3 text-left hover:bg-contrast/sm active:bg-contrast/sm-pressed" 263 + class="group flex flex-col items-stretch px-4 py-3 text-left hover:bg-contrast/sm active:bg-contrast/sm-pressed disabled:bg-transparent" 250 264 > 251 265 <div class="flex justify-between gap-4"> 252 - <p class="min-w-0 break-words text-sm font-medium">{props.label}</p> 266 + <p class="min-w-0 break-words text-sm font-medium group-disabled:text-contrast-muted group-disabled:opacity-50"> 267 + {props.label} 268 + </p> 253 269 254 - <span class="flex min-w-0 gap-1"> 270 + <span class="flex min-w-0 gap-1 group-disabled:opacity-50"> 255 271 <span class="min-w-0 break-words text-right text-de text-contrast-muted"> 256 272 {(() => { 257 273 const $selected = selected(); 258 274 if ($selected) { 259 275 return $selected.shortLabel ?? $selected.label; 260 276 } 277 + 278 + return props.value; 261 279 })()} 262 280 </span> 263 281 <ChevronRightOutlinedIcon class="-mr-1 mt-px shrink-0 rotate-90 text-lg text-contrast-muted" /> 264 282 </span> 265 283 </div> 266 284 267 - <p class="text-pretty break-words text-de text-contrast-muted empty:hidden">{props.description}</p> 285 + <p class="text-pretty break-words text-de text-contrast-muted empty:hidden group-disabled:opacity-50"> 286 + {props.description} 287 + </p> 268 288 </button> 269 289 ); 270 290 };
+9 -2
src/components/prompt.tsx
··· 6 6 import { useModalClose } from '~/lib/hooks/modal-close'; 7 7 import { on } from '~/lib/utils/misc'; 8 8 9 - import Button from './button'; 9 + import Button, { type ButtonProps } from './button'; 10 10 import { Backdrop } from './dialog'; 11 11 import { Fieldset } from './fieldset'; 12 12 ··· 102 102 export { PromptActions as Actions }; 103 103 104 104 export interface PromptActionProps { 105 + type?: ButtonProps['type']; 105 106 variant?: 'outline' | 'primary' | 'danger'; 106 107 noClose?: boolean; 107 108 disabled?: boolean; ··· 117 118 const handleClick = !noClose ? (onClick ? () => (close(), onClick()) : close) : onClick; 118 119 119 120 return ( 120 - <Button disabled={props.disabled} onClick={handleClick} variant={props.variant} size="lg"> 121 + <Button 122 + type={props.type} 123 + disabled={props.disabled} 124 + onClick={handleClick} 125 + variant={props.variant} 126 + size="lg" 127 + > 121 128 {props.children} 122 129 </Button> 123 130 );
+105
src/components/settings/content-translation/add-basa-instance-prompt.tsx
··· 1 + import { createMemo, createSignal } from 'solid-js'; 2 + 3 + import { XRPC, simpleFetchHandler } from '@atcute/client'; 4 + import { createMutation } from '@mary/solid-query'; 5 + 6 + import { formatQueryError } from '~/api/utils/error'; 7 + 8 + import { useModalContext } from '~/globals/modals'; 9 + 10 + import { autofocusNode, modelText } from '~/lib/input-refs'; 11 + import { useSession } from '~/lib/states/session'; 12 + import { type Validation, validate } from '~/lib/validation'; 13 + 14 + import * as Prompt from '~/components/prompt'; 15 + import TextInput from '~/components/text-input'; 16 + 17 + const urlValidations: Validation<URL>[] = [ 18 + [(url) => url.protocol === 'https:' || url.protocol === 'http:', `Has to be an HTTPS or HTTP URL`], 19 + [(url) => url.pathname === '/', `Can't have a pathname set`], 20 + [(url) => url.search.length <= 1, `Can't have a search string set`], 21 + [(url) => url.hash.length <= 1, `Can't have a hash string set`], 22 + ]; 23 + 24 + const AddBasaInstancePrompt = () => { 25 + const { close } = useModalContext(); 26 + 27 + const { currentAccount } = useSession(); 28 + const translationPrefs = currentAccount!.preferences.translation; 29 + 30 + const [error, setError] = createSignal<string>(); 31 + 32 + const [url, setUrl] = createSignal(''); 33 + 34 + const isUrlInvalid = createMemo(() => { 35 + const $url = url(); 36 + if ($url.trim().length === 0) { 37 + return `Can't be empty`; 38 + } 39 + 40 + const parsed = URL.parse($url); 41 + if (!parsed) { 42 + return `Invalid URL`; 43 + } 44 + 45 + return validate(parsed, urlValidations); 46 + }); 47 + 48 + const mutation = createMutation(() => ({ 49 + async mutationFn({ url }: { url: URL }) { 50 + const rpc = new XRPC({ handler: simpleFetchHandler({ service: url }) }); 51 + await rpc.get('x.basa.describeServer', {}); 52 + }, 53 + onSuccess(_data, { url }) { 54 + const href = url.toString(); 55 + 56 + if (!translationPrefs.instances.includes(href)) { 57 + translationPrefs.instances.push(href); 58 + } 59 + 60 + close(); 61 + }, 62 + onError(error) { 63 + setError(formatQueryError(error)); 64 + }, 65 + })); 66 + 67 + return ( 68 + <Prompt.Container disabled={mutation.isPending} maxWidth="md"> 69 + <Prompt.Title>Add instance</Prompt.Title> 70 + 71 + <form 72 + class="contents" 73 + onSubmit={(ev) => { 74 + ev.preventDefault(); 75 + mutation.mutate({ url: new URL(url()) }); 76 + }} 77 + > 78 + <div class="mt-4 flex flex-col gap-4"> 79 + <TextInput 80 + ref={(node) => { 81 + autofocusNode(node); 82 + modelText(node, url, setUrl); 83 + }} 84 + label="Instance URL" 85 + placeholder="https://example.com" 86 + error={url() && isUrlInvalid()} 87 + /> 88 + 89 + <p hidden={!error()} class="text-pretty text-de text-error"> 90 + {error()} 91 + </p> 92 + </div> 93 + 94 + <Prompt.Actions> 95 + <Prompt.Action type="submit" disabled={!!isUrlInvalid()} noClose variant="primary"> 96 + Add 97 + </Prompt.Action> 98 + <Prompt.Action>Cancel</Prompt.Action> 99 + </Prompt.Actions> 100 + </form> 101 + </Prompt.Container> 102 + ); 103 + }; 104 + 105 + export default AddBasaInstancePrompt;
+58 -1
src/components/threads/highlighted-post.tsx
··· 1 - import { Show, createMemo } from 'solid-js'; 1 + import { Show, createMemo, createSignal } from 'solid-js'; 2 2 3 3 import type { AppBskyFeedDefs, AppBskyFeedPost } from '@atcute/client/lexicons'; 4 4 ··· 9 9 import { createPostLikeMutation, createPostRepostMutation } from '~/api/mutations/post'; 10 10 import { parseAtUri } from '~/api/utils/strings'; 11 11 12 + import { primarySystemLanguage } from '~/globals/locales'; 12 13 import { openModal } from '~/globals/modals'; 13 14 14 15 import { formatCompact } from '~/lib/intl/number'; 16 + import type { ContentTranslationPreferences } from '~/lib/preferences/account'; 15 17 import { useModerationOptions } from '~/lib/states/moderation'; 18 + import { useSession } from '~/lib/states/session'; 16 19 17 20 import Avatar, { getUserAvatarType } from '../avatar'; 18 21 import ComposerDialogLazy from '../composer/composer-dialog-lazy'; ··· 33 36 34 37 export interface HighlightedPostProps { 35 38 post: AppBskyFeedDefs.PostView; 39 + translate: boolean; 36 40 /** Expected to be static */ 37 41 prev?: boolean; 42 + onTranslate?: () => void; 38 43 onPostDelete?: () => void; 39 44 onReplyPublish?: () => void; 40 45 } 41 46 42 47 const HighlightedPost = (props: HighlightedPostProps) => { 48 + const [showTl, setShowTl] = createSignal(false); 49 + 43 50 const post = () => props.post; 44 51 45 52 const moderationOptions = useModerationOptions(); 53 + const { currentAccount } = useSession(); 46 54 47 55 const author = () => post().author; 48 56 const record = () => post().record as AppBskyFeedPost.Record; ··· 128 136 129 137 <ContentHider ui={ui()} ignoreMute containerClass="mt-3" innerClass="mt-2"> 130 138 <RichText text={record().text} facets={record().facets} large /> 139 + 140 + {(() => { 141 + if (!currentAccount) { 142 + return; 143 + } 144 + 145 + if (props.translate) { 146 + return; 147 + } 148 + 149 + if (needTranslation(post(), currentAccount.preferences.translation)) { 150 + return ( 151 + <button 152 + onClick={() => props.onTranslate?.()} 153 + class="mt-1 self-start text-sm text-accent hover:underline" 154 + > 155 + Translate post 156 + </button> 157 + ); 158 + } 159 + })()} 160 + 131 161 {embed() && <Embed embed={embed()!} large moderation={moderation()} gutterTop />} 132 162 </ContentHider> 133 163 ··· 216 246 </Show> 217 247 ); 218 248 }; 249 + 250 + const needTranslation = (post: AppBskyFeedDefs.PostView, prefs?: ContentTranslationPreferences): boolean => { 251 + if (prefs && !prefs.enabled) { 252 + return false; 253 + } 254 + 255 + const record = post.record as AppBskyFeedPost.Record; 256 + const langs = record.langs; 257 + 258 + if (!langs || langs.length < 1 || !record.text) { 259 + return false; 260 + } 261 + 262 + const exclusions = prefs?.exclusions; 263 + 264 + let preferred = prefs?.to || 'system'; 265 + if (preferred === 'system') { 266 + preferred = primarySystemLanguage; 267 + } 268 + 269 + const unknowns = langs.filter((code) => { 270 + code = code.split('-')[0]; 271 + return code !== preferred && (!exclusions || !exclusions.includes(code)); 272 + }); 273 + 274 + return unknowns.length > 0; 275 + };
+2 -2
src/lib/preferences/account.ts
··· 60 60 export interface ContentTranslationPreferences { 61 61 /** Whether translations are enabled */ 62 62 enabled: boolean; 63 - /** Whether translations should be proxied */ 64 - proxy: boolean; 63 + /** URLs to Basa translate proxy instances */ 64 + instances: string[]; 65 65 /** Translate content to this language */ 66 66 to: 'system' | (string & {}); 67 67 /** Don't offer to translate on these languages */
+1 -1
src/lib/states/session.tsx
··· 242 242 }, 243 243 translation: { 244 244 enabled: false, 245 - proxy: true, 245 + instances: [], 246 246 to: 'system', 247 247 exclusions: [], 248 248 },
+4
src/routes.ts
··· 98 98 path: '/settings/content', 99 99 component: lazy(() => import('./views/settings-content')), 100 100 }, 101 + { 102 + path: '/settings/content/translation', 103 + component: lazy(() => import('./views/settings-content-translation')), 104 + }, 101 105 102 106 { 103 107 path: '/bookmarks',
+5 -1
src/views/post-thread.tsx
··· 1 - import { For, Match, Switch, createEffect, createMemo } from 'solid-js'; 1 + import { For, Match, Switch, createEffect, createMemo, createSignal } from 'solid-js'; 2 2 3 3 import { XRPCError } from '@atcute/client'; 4 4 import type { AppBskyFeedDefs, AppBskyFeedPost, At, Brand } from '@atcute/client/lexicons'; ··· 160 160 const { currentAccount } = useSession(); 161 161 const moderationOptions = useModerationOptions(); 162 162 163 + const [showTl, setShowTl] = createSignal(false); 164 + 163 165 const thread = createMemo(() => { 164 166 return createThreadData({ 165 167 thread: props.data, ··· 261 263 <HighlightedPost 262 264 post={thread().post} 263 265 prev={thread().ancestors.length !== 0 || isLoadingAncestor()} 266 + translate={showTl()} 267 + onTranslate={() => setShowTl(true)} 264 268 onReplyPublish={/* @once */ props.onReplyPublish} 265 269 onPostDelete={/* @once */ props.onPostDelete} 266 270 />
+142
src/views/settings-content-translation.tsx
··· 1 + import { For } from 'solid-js'; 2 + 3 + import { googleLanguages } from '~/api/basa/languages'; 4 + 5 + import { openModal } from '~/globals/modals'; 6 + 7 + import { getEnglishLanguageName } from '~/lib/intl/languages'; 8 + import { useSession } from '~/lib/states/session'; 9 + import { mapDefined } from '~/lib/utils/misc'; 10 + 11 + import * as Boxed from '~/components/boxed'; 12 + import IconButton from '~/components/icon-button'; 13 + import AddOutlinedIcon from '~/components/icons-central/add-outline'; 14 + import TrashOutlinedIcon from '~/components/icons-central/trash-outline'; 15 + import * as Page from '~/components/page'; 16 + import * as Prompt from '~/components/prompt'; 17 + import AddBasaInstancePrompt from '~/components/settings/content-translation/add-basa-instance-prompt'; 18 + 19 + const TranslationSettingsPage = () => { 20 + const { currentAccount } = useSession(); 21 + 22 + const preferences = currentAccount!.preferences; 23 + const translationPrefs = preferences.translation; 24 + 25 + const languageOptions: Boxed.SelectItemOption<string>[] = [ 26 + { 27 + value: 'system', 28 + label: 'System language', 29 + }, 30 + ...mapDefined(googleLanguages, (code): Boxed.SelectItemOption<string> | undefined => { 31 + const eng = getEnglishLanguageName(code); 32 + if (!eng) { 33 + return; 34 + } 35 + 36 + return { 37 + value: code, 38 + label: eng, 39 + }; 40 + }), 41 + ]; 42 + 43 + return ( 44 + <> 45 + <Page.Header> 46 + <Page.HeaderAccessory> 47 + <Page.Back to="/settings/content" /> 48 + </Page.HeaderAccessory> 49 + 50 + <Page.Heading title="Content translation" /> 51 + </Page.Header> 52 + 53 + <Boxed.Container> 54 + <Boxed.Group> 55 + <Boxed.List> 56 + <Boxed.ToggleItem 57 + label="Enable content translations" 58 + enabled={translationPrefs.enabled} 59 + onChange={(next) => (translationPrefs.enabled = next)} 60 + /> 61 + </Boxed.List> 62 + 63 + <Boxed.GroupBlurb> 64 + Makes use of Basa instances that will proxy your requests to like Google Translate or DeepL. 65 + Please read the privacy policies of the services and proxies before use. 66 + </Boxed.GroupBlurb> 67 + </Boxed.Group> 68 + 69 + <Boxed.Group> 70 + <Boxed.GroupHeader>Proxy instances</Boxed.GroupHeader> 71 + 72 + <Boxed.List> 73 + <For 74 + each={translationPrefs.instances} 75 + fallback={<div class="px-4 py-3 text-sm text-contrast-muted">No instances added yet</div>} 76 + > 77 + {(href) => ( 78 + <div class="flex items-center justify-between px-4 py-3"> 79 + <span class="text-ellipsis whitespace-nowrap text-sm font-medium">{href}</span> 80 + 81 + <IconButton 82 + icon={TrashOutlinedIcon} 83 + title="Remove this instance" 84 + onClick={() => { 85 + openModal(() => ( 86 + <Prompt.Confirm 87 + title="Remove this instance?" 88 + description="This instance will no longer be used for making translation requests" 89 + danger 90 + confirmLabel="Remove" 91 + onConfirm={() => { 92 + const index = translationPrefs.instances.indexOf(href); 93 + if (index !== -1) { 94 + translationPrefs.instances.splice(index, 1); 95 + } 96 + }} 97 + /> 98 + )); 99 + }} 100 + variant="danger" 101 + class="-my-1.5 -mr-2.5" 102 + /> 103 + </div> 104 + )} 105 + </For> 106 + 107 + <Boxed.ButtonItem 108 + label="Add new instance" 109 + icon={AddOutlinedIcon} 110 + onClick={() => { 111 + openModal(() => <AddBasaInstancePrompt />); 112 + }} 113 + /> 114 + </Boxed.List> 115 + </Boxed.Group> 116 + 117 + <Boxed.Group> 118 + <Boxed.GroupHeader>Translation options</Boxed.GroupHeader> 119 + 120 + <Boxed.List> 121 + <Boxed.SelectItem 122 + label="Translate into" 123 + value={translationPrefs.to} 124 + onChange={(next) => (translationPrefs.to = next)} 125 + options={languageOptions} 126 + /> 127 + 128 + <Boxed.ButtonItem 129 + label="Exclude languages from translation" 130 + description={`${translationPrefs.exclusions.length} languages excluded`} 131 + onClick={() => { 132 + // 133 + }} 134 + /> 135 + </Boxed.List> 136 + </Boxed.Group> 137 + </Boxed.Container> 138 + </> 139 + ); 140 + }; 141 + 142 + export default TranslationSettingsPage;
+44 -74
src/views/settings-content.tsx
··· 1 - import { primarySystemLanguage } from '~/globals/locales'; 2 - 3 1 import { LANGUAGE_CODES, getEnglishLanguageName } from '~/lib/intl/languages'; 4 2 import { useSession } from '~/lib/states/session'; 5 3 import { mapDefined } from '~/lib/utils/misc'; 6 4 7 5 import * as Boxed from '~/components/boxed'; 6 + import TranslateOutlinedIcon from '~/components/icons-central/translate-outline'; 8 7 import * as Page from '~/components/page'; 9 8 10 9 const ContentSettingsPage = () => { 11 - const { currentAccount } = useSession(); 12 - 13 - const preferences = currentAccount!.preferences; 14 - const composerPrefs = preferences.composer; 15 - const translationPrefs = preferences.translation; 16 - 17 - const languageOptions = getLanguageOptions(); 18 - 19 10 return ( 20 11 <> 21 12 <Page.Header> ··· 28 19 29 20 <Boxed.Container> 30 21 <Boxed.Group> 31 - <Boxed.GroupHeader>Content you post</Boxed.GroupHeader> 32 - 33 22 <Boxed.List> 34 - <Boxed.SelectItem 35 - label="Post language" 36 - value={composerPrefs.defaultPostLanguage} 37 - onChange={(next) => (composerPrefs.defaultPostLanguage = next)} 38 - options={languageOptions} 39 - /> 40 - 41 - <Boxed.SelectItem 42 - label="Who can reply to my posts" 43 - value={composerPrefs.defaultReplyGate} 44 - onChange={(next) => (composerPrefs.defaultReplyGate = next)} 45 - options={[ 46 - { value: 'everyone', label: `Everyone` }, 47 - { value: 'follows', label: `Followed users` }, 48 - { value: 'mentions', label: `Mentioned users` }, 49 - ]} 23 + <Boxed.LinkItem 24 + to="/settings/content/translation" 25 + label="Content translation" 26 + icon={TranslateOutlinedIcon} 50 27 /> 51 28 </Boxed.List> 52 - 53 - <Boxed.GroupBlurb>Altering these settings will not affect existing posts</Boxed.GroupBlurb> 54 29 </Boxed.Group> 55 30 56 - <Boxed.Group> 57 - <Boxed.GroupHeader>Content translations</Boxed.GroupHeader> 58 - 59 - <Boxed.List> 60 - <Boxed.ToggleItem 61 - label="Use Google Translate" 62 - enabled={translationPrefs.enabled} 63 - onChange={(next) => (translationPrefs.enabled = next)} 64 - /> 65 - 66 - {translationPrefs.enabled && ( 67 - <> 68 - <Boxed.SelectItem 69 - label="Translate into" 70 - value={translationPrefs.to} 71 - onChange={(next) => (translationPrefs.to = next)} 72 - options={languageOptions} 73 - /> 74 - 75 - <Boxed.ButtonItem 76 - label="Exclude languages from translation" 77 - description={`${translationPrefs.exclusions.length} languages excluded`} 78 - onClick={() => { 79 - // 80 - }} 81 - /> 82 - </> 83 - )} 84 - </Boxed.List> 85 - 86 - {translationPrefs.enabled && ( 87 - <Boxed.List> 88 - <Boxed.ToggleItem 89 - label="Proxy translation requests" 90 - description="Send translations through a proxy service rather than directly. Availability might be limited" 91 - enabled={translationPrefs.proxy} 92 - onChange={(next) => (translationPrefs.proxy = next)} 93 - /> 94 - </Boxed.List> 95 - )} 96 - </Boxed.Group> 31 + <ComposerSettingsGroup /> 97 32 </Boxed.Container> 98 33 </> 99 34 ); ··· 101 36 102 37 export default ContentSettingsPage; 103 38 104 - const getLanguageOptions = (): Boxed.SelectItemOption<string>[] => { 105 - const systemLanguage = getEnglishLanguageName(primarySystemLanguage); 39 + const ComposerSettingsGroup = () => { 40 + const { currentAccount } = useSession(); 41 + 42 + const preferences = currentAccount!.preferences; 43 + const composerPrefs = preferences.composer; 44 + 45 + const languageOptions = getLanguageOptions(); 46 + 47 + return ( 48 + <Boxed.Group> 49 + <Boxed.GroupHeader>Content you post</Boxed.GroupHeader> 50 + 51 + <Boxed.List> 52 + <Boxed.SelectItem 53 + label="Post language" 54 + value={composerPrefs.defaultPostLanguage} 55 + onChange={(next) => (composerPrefs.defaultPostLanguage = next)} 56 + options={languageOptions} 57 + /> 58 + 59 + <Boxed.SelectItem 60 + label="Who can reply to my posts" 61 + value={composerPrefs.defaultReplyGate} 62 + onChange={(next) => (composerPrefs.defaultReplyGate = next)} 63 + options={[ 64 + { value: 'everyone', label: `Everyone` }, 65 + { value: 'follows', label: `Followed users` }, 66 + { value: 'mentions', label: `Mentioned users` }, 67 + ]} 68 + /> 69 + </Boxed.List> 106 70 71 + <Boxed.GroupBlurb>Altering these settings will not affect existing posts</Boxed.GroupBlurb> 72 + </Boxed.Group> 73 + ); 74 + }; 75 + 76 + const getLanguageOptions = (): Boxed.SelectItemOption<string>[] => { 107 77 return [ 108 78 { 109 79 value: 'system', 110 - label: `System default (${systemLanguage})`, 80 + label: `System default`, 111 81 }, 112 82 ...mapDefined(LANGUAGE_CODES, (code): Boxed.SelectItemOption<string> | undefined => { 113 83 const eng = getEnglishLanguageName(code);