parent
							
								
									9c3f5929c7
								
							
						
					
					
						commit
						136883fd94
					
				
					 5 changed files with 123 additions and 46 deletions
				
			
		|  | @ -24,8 +24,9 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release | ||||||
| 
 | 
 | ||||||
| **Bugs:** | **Bugs:** | ||||||
| 
 | 
 | ||||||
| * Web app: English language strings fixes ([#203](https://github.com/binwiederhier/ntfy/issues/203), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov)) | * Web app: English language strings fixes, additional descriptions for settings ([#203](https://github.com/binwiederhier/ntfy/issues/203), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov)) | ||||||
| * Web app: Show error message snackbar when sending test notification fails ([#205](https://github.com/binwiederhier/ntfy/issues/205), thanks to [@cmeis](https://github.com/cmeis)) | * Web app: Show error message snackbar when sending test notification fails ([#205](https://github.com/binwiederhier/ntfy/issues/205), thanks to [@cmeis](https://github.com/cmeis)) | ||||||
|  | * Web app: basic URL validation in user management ([#204](https://github.com/binwiederhier/ntfy/issues/204), thanks to [@cmeis](https://github.com/cmeis)) | ||||||
| 
 | 
 | ||||||
| **Translations (web app):** | **Translations (web app):** | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -33,7 +33,7 @@ | ||||||
|   "notifications_none_for_any_title": "You haven't received any notifications.", |   "notifications_none_for_any_title": "You haven't received any notifications.", | ||||||
|   "notifications_none_for_any_description": "To send notifications to a topic, simply PUT or POST to the topic URL. Here's an example using one of your topics.", |   "notifications_none_for_any_description": "To send notifications to a topic, simply PUT or POST to the topic URL. Here's an example using one of your topics.", | ||||||
|   "notifications_no_subscriptions_title": "It looks like you don't have any subscriptions yet.", |   "notifications_no_subscriptions_title": "It looks like you don't have any subscriptions yet.", | ||||||
|   "notifications_no_subscriptions_description": "Click the \"Add subscription\" link to create or subscribe to a topic. After that, you can send messages via PUT or POST and you'll receive notifications here.", |   "notifications_no_subscriptions_description": "Click the \"{{linktext}}\" link to create or subscribe to a topic. After that, you can send messages via PUT or POST and you'll receive notifications here.", | ||||||
|   "notifications_example": "Example", |   "notifications_example": "Example", | ||||||
|   "notifications_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.", |   "notifications_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.", | ||||||
|   "notifications_loading": "Loading notifications …", |   "notifications_loading": "Loading notifications …", | ||||||
|  | @ -103,8 +103,13 @@ | ||||||
|   "subscribe_dialog_error_user_anonymous": "anonymous", |   "subscribe_dialog_error_user_anonymous": "anonymous", | ||||||
|   "prefs_notifications_title": "Notifications", |   "prefs_notifications_title": "Notifications", | ||||||
|   "prefs_notifications_sound_title": "Notification sound", |   "prefs_notifications_sound_title": "Notification sound", | ||||||
|  |   "prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive", | ||||||
|  |   "prefs_notifications_sound_description_some": "Notifications play the {{sound}} sound when they arrive", | ||||||
|   "prefs_notifications_sound_no_sound": "No sound", |   "prefs_notifications_sound_no_sound": "No sound", | ||||||
|   "prefs_notifications_min_priority_title": "Minimum priority", |   "prefs_notifications_min_priority_title": "Minimum priority", | ||||||
|  |   "prefs_notifications_min_priority_description_any": "Showing all notifications, regardless of priority", | ||||||
|  |   "prefs_notifications_min_priority_description_x_or_higher": "Show notifications if priority is {{number}} ({{name}}) or above", | ||||||
|  |   "prefs_notifications_min_priority_description_max": "Show notifications if priority is 5 (max)", | ||||||
|   "prefs_notifications_min_priority_any": "Any priority", |   "prefs_notifications_min_priority_any": "Any priority", | ||||||
|   "prefs_notifications_min_priority_low_and_higher": "Low priority and higher", |   "prefs_notifications_min_priority_low_and_higher": "Low priority and higher", | ||||||
|   "prefs_notifications_min_priority_default_and_higher": "Default priority and higher", |   "prefs_notifications_min_priority_default_and_higher": "Default priority and higher", | ||||||
|  | @ -116,6 +121,11 @@ | ||||||
|   "prefs_notifications_delete_after_one_day": "After one day", |   "prefs_notifications_delete_after_one_day": "After one day", | ||||||
|   "prefs_notifications_delete_after_one_week": "After one week", |   "prefs_notifications_delete_after_one_week": "After one week", | ||||||
|   "prefs_notifications_delete_after_one_month": "After one month", |   "prefs_notifications_delete_after_one_month": "After one month", | ||||||
|  |   "prefs_notifications_delete_after_never_description": "Notifications are never auto-deleted", | ||||||
|  |   "prefs_notifications_delete_after_three_hours_description": "Notifications are auto-deleted after three hours", | ||||||
|  |   "prefs_notifications_delete_after_one_day_description": "Notifications are auto-deleted after one day", | ||||||
|  |   "prefs_notifications_delete_after_one_week_description": "Notifications are auto-deleted after one week", | ||||||
|  |   "prefs_notifications_delete_after_one_month_description": "Notifications are auto-deleted after one month", | ||||||
|   "prefs_users_title": "Manage users", |   "prefs_users_title": "Manage users", | ||||||
|   "prefs_users_description": "Add/remove users for your protected topics here. Please note that username and password are stored in the browser's local storage.", |   "prefs_users_description": "Add/remove users for your protected topics here. Please note that username and password are stored in the browser's local storage.", | ||||||
|   "prefs_users_add_button": "Add user", |   "prefs_users_add_button": "Add user", | ||||||
|  | @ -131,6 +141,11 @@ | ||||||
|   "prefs_users_dialog_button_save": "Save", |   "prefs_users_dialog_button_save": "Save", | ||||||
|   "prefs_appearance_title": "Appearance", |   "prefs_appearance_title": "Appearance", | ||||||
|   "prefs_appearance_language_title": "Language", |   "prefs_appearance_language_title": "Language", | ||||||
|  |   "priority_min": "min", | ||||||
|  |   "priority_low": "low", | ||||||
|  |   "priority_default": "default", | ||||||
|  |   "priority_high": "high", | ||||||
|  |   "priority_max": "max", | ||||||
|   "error_boundary_title": "Oh no, ntfy crashed", |   "error_boundary_title": "Oh no, ntfy crashed", | ||||||
|   "error_boundary_description": "This should obviously not happen. Very sorry about this.<br/>If you have a minute, please <githubLink>report this on GitHub</githubLink>, or let us know via <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>.", |   "error_boundary_description": "This should obviously not happen. Very sorry about this.<br/>If you have a minute, please <githubLink>report this on GitHub</githubLink>, or let us know via <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>.", | ||||||
|   "error_boundary_button_copy_stack_trace": "Copy stack trace", |   "error_boundary_button_copy_stack_trace": "Copy stack trace", | ||||||
|  |  | ||||||
|  | @ -24,7 +24,7 @@ export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; | ||||||
| export const expandSecureUrl = (url) => `https://${url}`; | export const expandSecureUrl = (url) => `https://${url}`; | ||||||
| 
 | 
 | ||||||
| export const validUrl = (url) => { | export const validUrl = (url) => { | ||||||
|     return url.match(/^https?:\/\//); |     return url.match(/^https?:\/\/.+/); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const validTopic = (topic) => { | export const validTopic = (topic) => { | ||||||
|  | @ -153,17 +153,38 @@ export const openUrl = (url) => { | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const sounds = { | export const sounds = { | ||||||
|     "beep": beep, |     "ding": { | ||||||
|     "juntos": juntos, |         file: ding, | ||||||
|     "pristine": pristine, |         label: "Ding" | ||||||
|     "ding": ding, |     }, | ||||||
|     "dadum": dadum, |     "juntos": { | ||||||
|     "pop": pop, |         file: juntos, | ||||||
|     "pop-swoosh": popSwoosh |         label: "Juntos" | ||||||
|  |     }, | ||||||
|  |     "pristine": { | ||||||
|  |         file: pristine, | ||||||
|  |         label: "Pristine" | ||||||
|  |     }, | ||||||
|  |     "dadum": { | ||||||
|  |         file: dadum, | ||||||
|  |         label: "Dadum" | ||||||
|  |     }, | ||||||
|  |     "pop": { | ||||||
|  |         file: pop, | ||||||
|  |         label: "Pop" | ||||||
|  |     }, | ||||||
|  |     "pop-swoosh": { | ||||||
|  |         file: popSwoosh, | ||||||
|  |         label: "Pop swoosh" | ||||||
|  |     }, | ||||||
|  |     "beep": { | ||||||
|  |         file: beep, | ||||||
|  |         label: "Beep" | ||||||
|  |     } | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const playSound = async (sound) => { | export const playSound = async (id) => { | ||||||
|     const audio = new Audio(sounds[sound]); |     const audio = new Audio(sounds[id].file); | ||||||
|     return audio.play(); |     return audio.play(); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -389,7 +389,9 @@ const NoSubscriptions = () => { | ||||||
|                 {t("notifications_no_subscriptions_title")} |                 {t("notifications_no_subscriptions_title")} | ||||||
|             </Typography> |             </Typography> | ||||||
|             <Paragraph> |             <Paragraph> | ||||||
|                 {t("notifications_no_subscriptions_description")} |                 {t("notifications_no_subscriptions_description", { | ||||||
|  |                     linktext: t("nav_button_subscribe") | ||||||
|  |                 })} | ||||||
|             </Paragraph> |             </Paragraph> | ||||||
|             <Paragraph> |             <Paragraph> | ||||||
|                 <ForMoreDetails/> |                 <ForMoreDetails/> | ||||||
|  |  | ||||||
|  | @ -32,8 +32,13 @@ import DialogTitle from "@mui/material/DialogTitle"; | ||||||
| import DialogContent from "@mui/material/DialogContent"; | import DialogContent from "@mui/material/DialogContent"; | ||||||
| import DialogActions from "@mui/material/DialogActions"; | import DialogActions from "@mui/material/DialogActions"; | ||||||
| import userManager from "../app/UserManager"; | import userManager from "../app/UserManager"; | ||||||
| import {playSound, shuffle} from "../app/utils"; | import {playSound, shuffle, sounds, validUrl} from "../app/utils"; | ||||||
| import {useTranslation} from "react-i18next"; | import {useTranslation} from "react-i18next"; | ||||||
|  | import priority1 from "../img/priority-1.svg"; | ||||||
|  | import priority2 from "../img/priority-2.svg"; | ||||||
|  | import priority3 from "../img/priority-3.svg"; | ||||||
|  | import priority4 from "../img/priority-4.svg"; | ||||||
|  | import priority5 from "../img/priority-5.svg"; | ||||||
| 
 | 
 | ||||||
| const Preferences = () => { | const Preferences = () => { | ||||||
|     return ( |     return ( | ||||||
|  | @ -51,7 +56,7 @@ const Notifications = () => { | ||||||
|     const { t } = useTranslation(); |     const { t } = useTranslation(); | ||||||
|     return ( |     return ( | ||||||
|         <Card sx={{p: 3}}> |         <Card sx={{p: 3}}> | ||||||
|             <Typography variant="h5"> |             <Typography variant="h5" sx={{marginBottom: 2}}> | ||||||
|                 {t("prefs_notifications_title")} |                 {t("prefs_notifications_title")} | ||||||
|             </Typography> |             </Typography> | ||||||
|             <PrefGroup> |             <PrefGroup> | ||||||
|  | @ -72,19 +77,19 @@ const Sound = () => { | ||||||
|     if (!sound) { |     if (!sound) { | ||||||
|         return null; // While loading
 |         return null; // While loading
 | ||||||
|     } |     } | ||||||
|  |     let description; | ||||||
|  |     if (sound === "none") { | ||||||
|  |         description = t("prefs_notifications_sound_description_none"); | ||||||
|  |     } else { | ||||||
|  |         description = t("prefs_notifications_sound_description_some", { sound: sounds[sound].label }); | ||||||
|  |     } | ||||||
|     return ( |     return ( | ||||||
|         <Pref title={t("prefs_notifications_sound_title")}> |         <Pref title={t("prefs_notifications_sound_title")} description={description}> | ||||||
|             <div style={{ display: 'flex', width: '100%' }}> |             <div style={{ display: 'flex', width: '100%' }}> | ||||||
|                 <FormControl fullWidth variant="standard" sx={{ margin: 1 }}> |                 <FormControl fullWidth variant="standard" sx={{ margin: 1 }}> | ||||||
|                     <Select value={sound} onChange={handleChange}> |                     <Select value={sound} onChange={handleChange}> | ||||||
|                         <MenuItem value={"none"}>{t("prefs_notifications_sound_no_sound")}</MenuItem> |                         <MenuItem value={"none"}>{t("prefs_notifications_sound_no_sound")}</MenuItem> | ||||||
|                         <MenuItem value={"ding"}>Ding</MenuItem> |                         {Object.entries(sounds).map(s => <MenuItem key={s[0]} value={s[0]}>{s[1].label}</MenuItem>)} | ||||||
|                         <MenuItem value={"juntos"}>Juntos</MenuItem> |  | ||||||
|                         <MenuItem value={"pristine"}>Pristine</MenuItem> |  | ||||||
|                         <MenuItem value={"dadum"}>Dadum</MenuItem> |  | ||||||
|                         <MenuItem value={"pop"}>Pop</MenuItem> |  | ||||||
|                         <MenuItem value={"pop-swoosh"}>Pop swoosh</MenuItem> |  | ||||||
|                         <MenuItem value={"beep"}>Beep</MenuItem> |  | ||||||
|                     </Select> |                     </Select> | ||||||
|                 </FormControl> |                 </FormControl> | ||||||
|                 <IconButton onClick={() => playSound(sound)} disabled={sound === "none"}> |                 <IconButton onClick={() => playSound(sound)} disabled={sound === "none"}> | ||||||
|  | @ -104,8 +109,26 @@ const MinPriority = () => { | ||||||
|     if (!minPriority) { |     if (!minPriority) { | ||||||
|         return null; // While loading
 |         return null; // While loading
 | ||||||
|     } |     } | ||||||
|  |     const priorities = { | ||||||
|  |         1: t("priority_min"), | ||||||
|  |         2: t("priority_low"), | ||||||
|  |         3: t("priority_default"), | ||||||
|  |         4: t("priority_high"), | ||||||
|  |         5: t("priority_max") | ||||||
|  |     } | ||||||
|  |     let description; | ||||||
|  |     if (minPriority === 1) { | ||||||
|  |         description = t("prefs_notifications_min_priority_description_any"); | ||||||
|  |     } else if (minPriority === 5) { | ||||||
|  |         description = t("prefs_notifications_min_priority_description_max"); | ||||||
|  |     } else { | ||||||
|  |         description = t("prefs_notifications_min_priority_description_x_or_higher", { | ||||||
|  |             number: minPriority, | ||||||
|  |             name: priorities[minPriority] | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|     return ( |     return ( | ||||||
|         <Pref title={t("prefs_notifications_min_priority_title")}> |         <Pref title={t("prefs_notifications_min_priority_title")} description={description}> | ||||||
|             <FormControl fullWidth variant="standard" sx={{ m: 1 }}> |             <FormControl fullWidth variant="standard" sx={{ m: 1 }}> | ||||||
|                 <Select value={minPriority} onChange={handleChange}> |                 <Select value={minPriority} onChange={handleChange}> | ||||||
|                     <MenuItem value={1}>{t("prefs_notifications_min_priority_any")}</MenuItem> |                     <MenuItem value={1}>{t("prefs_notifications_min_priority_any")}</MenuItem> | ||||||
|  | @ -125,11 +148,20 @@ const DeleteAfter = () => { | ||||||
|     const handleChange = async (ev) => { |     const handleChange = async (ev) => { | ||||||
|         await prefs.setDeleteAfter(ev.target.value); |         await prefs.setDeleteAfter(ev.target.value); | ||||||
|     } |     } | ||||||
|     if (!deleteAfter) { |     if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0"
 | ||||||
|         return null; // While loading
 |         return null; // While loading
 | ||||||
|     } |     } | ||||||
|  |     const description = (() => { | ||||||
|  |         switch (deleteAfter) { | ||||||
|  |             case 0: return t("prefs_notifications_delete_after_never_description"); | ||||||
|  |             case 10800: return t("prefs_notifications_delete_after_three_hours_description"); | ||||||
|  |             case 86400: return t("prefs_notifications_delete_after_one_day_description"); | ||||||
|  |             case 604800: return t("prefs_notifications_delete_after_one_week_description"); | ||||||
|  |             case 2592000: return t("prefs_notifications_delete_after_one_month_description"); | ||||||
|  |         } | ||||||
|  |     })(); | ||||||
|     return ( |     return ( | ||||||
|         <Pref title={t("prefs_notifications_delete_after_title")}> |         <Pref title={t("prefs_notifications_delete_after_title")} description={description}> | ||||||
|             <FormControl fullWidth variant="standard" sx={{ m: 1 }}> |             <FormControl fullWidth variant="standard" sx={{ m: 1 }}> | ||||||
|                 <Select value={deleteAfter} onChange={handleChange}> |                 <Select value={deleteAfter} onChange={handleChange}> | ||||||
|                     <MenuItem value={0}>{t("prefs_notifications_delete_after_never")}</MenuItem> |                     <MenuItem value={0}>{t("prefs_notifications_delete_after_never")}</MenuItem> | ||||||
|  | @ -145,10 +177,7 @@ const DeleteAfter = () => { | ||||||
| 
 | 
 | ||||||
| const PrefGroup = (props) => { | const PrefGroup = (props) => { | ||||||
|     return ( |     return ( | ||||||
|         <div style={{ |         <div> | ||||||
|             display: 'flex', |  | ||||||
|             flexWrap: 'wrap' |  | ||||||
|         }}> |  | ||||||
|             {props.children} |             {props.children} | ||||||
|         </div> |         </div> | ||||||
|     ) |     ) | ||||||
|  | @ -156,26 +185,31 @@ const PrefGroup = (props) => { | ||||||
| 
 | 
 | ||||||
| const Pref = (props) => { | const Pref = (props) => { | ||||||
|     return ( |     return ( | ||||||
|         <> |         <div style={{ | ||||||
|  |             display: "flex", | ||||||
|  |             flexDirection: "row", | ||||||
|  |             marginTop: "10px", | ||||||
|  |             marginBottom: "20px", | ||||||
|  |         }}> | ||||||
|             <div style={{ |             <div style={{ | ||||||
|                 flex: '1 0 30%', |                 flex: '1 0 40%', | ||||||
|                 display: 'inline-flex', |                 display: 'flex', | ||||||
|                 flexDirection: 'column', |                 flexDirection: 'column', | ||||||
|                 minHeight: '60px', |                 justifyContent: 'center', | ||||||
|                 justifyContent: 'center' |                 paddingRight: '30px' | ||||||
|             }}> |             }}> | ||||||
|                 <b>{props.title}</b> |                 <div><b>{props.title}</b></div> | ||||||
|  |                 {props.description && <div><em>{props.description}</em></div>} | ||||||
|             </div> |             </div> | ||||||
|             <div style={{ |             <div style={{ | ||||||
|                 flex: '1 0 calc(70% - 50px)', |                 flex: '1 0 calc(60% - 50px)', | ||||||
|                 display: 'inline-flex', |                 display: 'flex', | ||||||
|                 flexDirection: 'column', |                 flexDirection: 'column', | ||||||
|                 minHeight: '60px', |  | ||||||
|                 justifyContent: 'center' |                 justifyContent: 'center' | ||||||
|             }}> |             }}> | ||||||
|                 {props.children} |                 {props.children} | ||||||
|             </div> |             </div> | ||||||
|         </> |         </div> | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -202,8 +236,8 @@ const Users = () => { | ||||||
|     }; |     }; | ||||||
|     return ( |     return ( | ||||||
|         <Card sx={{ padding: 1 }}> |         <Card sx={{ padding: 1 }}> | ||||||
|             <CardContent> |             <CardContent sx={{ paddingBottom: 1 }}> | ||||||
|                 <Typography variant="h5"> |                 <Typography variant="h5" sx={{marginBottom: 2}}> | ||||||
|                     {t("prefs_users_title")} |                     {t("prefs_users_title")} | ||||||
|                 </Typography> |                 </Typography> | ||||||
|                 <Paragraph> |                 <Paragraph> | ||||||
|  | @ -260,7 +294,7 @@ const UserTable = (props) => { | ||||||
|         <Table size="small"> |         <Table size="small"> | ||||||
|             <TableHead> |             <TableHead> | ||||||
|                 <TableRow> |                 <TableRow> | ||||||
|                     <TableCell>{t("prefs_users_table_user_header")}</TableCell> |                     <TableCell sx={{paddingLeft: 0}}>{t("prefs_users_table_user_header")}</TableCell> | ||||||
|                     <TableCell>{t("prefs_users_table_base_url_header")}</TableCell> |                     <TableCell>{t("prefs_users_table_base_url_header")}</TableCell> | ||||||
|                     <TableCell/> |                     <TableCell/> | ||||||
|                 </TableRow> |                 </TableRow> | ||||||
|  | @ -271,7 +305,7 @@ const UserTable = (props) => { | ||||||
|                         key={user.baseUrl} |                         key={user.baseUrl} | ||||||
|                         sx={{ '&:last-child td, &:last-child th': { border: 0 } }} |                         sx={{ '&:last-child td, &:last-child th': { border: 0 } }} | ||||||
|                     > |                     > | ||||||
|                         <TableCell component="th" scope="row">{user.username}</TableCell> |                         <TableCell component="th" scope="row" sx={{paddingLeft: 0}}>{user.username}</TableCell> | ||||||
|                         <TableCell>{user.baseUrl}</TableCell> |                         <TableCell>{user.baseUrl}</TableCell> | ||||||
|                         <TableCell align="right"> |                         <TableCell align="right"> | ||||||
|                             <IconButton onClick={() => handleEditClick(user)}> |                             <IconButton onClick={() => handleEditClick(user)}> | ||||||
|  | @ -307,8 +341,12 @@ const UserDialog = (props) => { | ||||||
|         if (editMode) { |         if (editMode) { | ||||||
|             return username.length > 0 && password.length > 0; |             return username.length > 0 && password.length > 0; | ||||||
|         } |         } | ||||||
|  |         const baseUrlValid = validUrl(baseUrl); | ||||||
|         const baseUrlExists = props.users?.map(user => user.baseUrl).includes(baseUrl); |         const baseUrlExists = props.users?.map(user => user.baseUrl).includes(baseUrl); | ||||||
|         return !baseUrlExists && username.length > 0 && password.length > 0; |         return baseUrlValid | ||||||
|  |             && !baseUrlExists | ||||||
|  |             && username.length > 0 | ||||||
|  |             && password.length > 0; | ||||||
|     })(); |     })(); | ||||||
|     const handleSubmit = async () => { |     const handleSubmit = async () => { | ||||||
|         props.onSubmit({ |         props.onSubmit({ | ||||||
|  | @ -373,7 +411,7 @@ const Appearance = () => { | ||||||
|     const { t } = useTranslation(); |     const { t } = useTranslation(); | ||||||
|     return ( |     return ( | ||||||
|         <Card sx={{p: 3}}> |         <Card sx={{p: 3}}> | ||||||
|             <Typography variant="h5"> |             <Typography variant="h5" sx={{marginBottom: 2}}> | ||||||
|                 {t("prefs_appearance_title")} |                 {t("prefs_appearance_title")} | ||||||
|             </Typography> |             </Typography> | ||||||
|             <PrefGroup> |             <PrefGroup> | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue