diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
index a5e454bf..f89d5146 100644
--- a/.git-blame-ignore-revs
+++ b/.git-blame-ignore-revs
@@ -32,6 +32,8 @@ cf39d8dedd98848ab3078a6a3abebd1e3a7eaaec
ffc9425c81cabdda2233c29032d0fe5bba0ac4eb
f0f8b63f87893b291e2c7a1f66b19d3d5fa7e06e
+# Auto-format 2026-02-18
+1fb5ab33d5b9d94da098f3858e7784d4f9158b0f
# Auto-format 2026-02-16
eedc252286e272734f95b3ba49f87bfac9c0ae1a
@@ -52,3 +54,9 @@ fbda6ec72a7aca61f1b28496935a3cd1f8866b6d
# Auto-format 2026-07-06
72143d617dedda52e16963cbace35da35d457413
+
+# Auto-format 2026-10-04
+00a8662195ac491c1b0943f6c214f1f41fb11013
+
+# Auto-format 2026-10-30
+17416583673e6321d912eff090ed8d69f56528b1
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
new file mode 100644
index 00000000..3c026b6e
--- /dev/null
+++ b/.github/workflows/check.yml
@@ -0,0 +1,32 @@
+name: Check
+
+permissions:
+ contents: read
+
+on:
+ pull_request:
+ branches:
+ - main
+ - development
+
+concurrency:
+ group: check-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ typecheck:
+ name: Typecheck
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ - uses: pnpm/action-setup@v6
+
+ - uses: actions/setup-node@v6
+ with:
+ node-version-file: '.nvmrc'
+ cache: pnpm
+
+ - run: pnpm install --frozen-lockfile
+
+ - run: pnpm typecheck
diff --git a/.nvmrc b/.nvmrc
index f3c88209..474d750b 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-v24.15.0
+v24.22.1
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 32a47874..59e8c6f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,13 +7,182 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [2.5.0-beta.0] - 2026-11-06
+
+### Added
+
+- Settings rework! Everything is now in a single menu, and you can set chat settings globally. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/725af0eb9acca4e48c4934c8bae64d2f7d7e972b)
+- Added a high-contrast mode setting. It's under "Accessibility" in the new settings menu. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/d464c6c44d410f2ac6c30aff844b5ec04c3f62e5)
+- New colorblind settings for different types of color blindness. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/b95ae133dc1ffa3856edb921ee629c66acff13f9)
+ - These are also found in the accessibility tab now.
+- Split the "Log messages" setting between one for PMs, and one for channel messages. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/9ba9e46468f5d987a123b9fa35e86a0e061e4ddb)
+ - If you have previously disabled the log messages setting, you'll _also_ need to disable this new one. It's enabled by default.
+
+### Changed
+
+- New app icons: Featuring a new version of the Windows/ Linux icon, the MacOS one, and the .dmg archive. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/359493d8ee924ce929b5b911bf1d45573bebd895)
+- MacOS versions of Horizon now use unstyled, floating scrollbars instead of static, custom ones. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/3493ef0ecc9db29d41e37ef58768b912aa318b7c)
+
+### Fixed
+
+- Switching back and forth between the console tab and a channel now keeps the members list as the active user list tab again. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/8031f4c015b4e27f9bdd2245658236ba87dbc0a5)
+- Fixed issues where having specific combinations for logging messages disabled per-character (but enabled for a conversation) would not save your own messages. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/dd2478288372f026df7db4139057d994c3d40ad3)
+
+### Development
+
+- Added a new member to `core` for platform-related functionality, to help decouple some Electron-specific code from the chat module. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/8090bccd8f4fd0f91ccdb900d990cd73ae829505)
+- Spellcheck language settings are now decoupled from Electron, and are automatically hidden from the UI if the platform does not expose any available languages.. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/16834353e94bad55405850bb11669ce1dfc4d004)
+- Tab components now support a vertical mode, and allow you to prefix a title with icons. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/d2a309af74c90e07881479e2eda33327c2a59f89)
+
+### Merged Pull Requests
+
+- https://github.com/Fchat-Horizon/Horizon/pull/640 by @FatCatClient
+- https://github.com/Fchat-Horizon/Horizon/pull/956 by @FatCatClient
+- https://github.com/Fchat-Horizon/Horizon/pull/964 by @FatCatClient
+- https://github.com/Fchat-Horizon/Horizon/pull/1001 by @FatCatClient
+- https://github.com/Fchat-Horizon/Horizon/pull/1021 by @FatCatClient
+- https://github.com/Fchat-Horizon/Horizon/pull/1021 by @FatCatClient
+- https://github.com/Fchat-Horizon/Horizon/pull/1027 by @freenutsxd
+
+## [2.4.5] - 2026-10-28
+
+### Fixed
+
+- Clicking 'Hide ads' now retroactively hides that user's ads, instead of only hiding future ads sent by them. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/977f6b748a045b87425b1d0119b188c5f3df53c1)
+- Fixed the log viewer not showing new conversations (or new dates for existing conversations) until you reconnect. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/2c682d12c76211b168dc0f451eab8f8e1cb9b14d)
+- The Eicon picker dialog no longer shrinks to a very narrow size if a search result doesn't have a lot of entries. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/c1c8eb2c8e6e0f26d539e8f16b8bff4fe85b58fa)
+
+### Development
+
+- Upgraded Electron to 43.8.4 (was 43.8.1) to resolve some potential security issues with NodeJS. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/115fe705028fe7fbd60ea9bdde7059b261e605e3)
+- Upgraded the project build tools version of NodeJS to 24.22.1. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/b1df0acf565f15a02094a9c55ad7fda276b98e2a)
+
+### Merged Pull Requests
+
+- https://github.com/Fchat-Horizon/Horizon/pull/953 by @freenutsxd
+- https://github.com/Fchat-Horizon/Horizon/pull/968 by @freenutsxd
+- https://github.com/Fchat-Horizon/Horizon/pull/1000 by @FatCatClient
+- https://github.com/Fchat-Horizon/Horizon/pull/1005 by @freenutsxd
+- https://github.com/Fchat-Horizon/Horizon/pull/1006 by @github-actions
+
+## [2.4.4] - 2026-10-26
+
+### Changed
+
+- Messing up a slash-command by prefixing whitespace now warns you before sending it. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/c15f8446ef570c977d9d8b501fecdd75405b1685)
+ - You can press Enter again to allow it through regardless.
+- Reworked the bbcode editor/ chatbox and Eicon selector dialog to work better in mobile-sized viewports. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/f37e73959ea03b7652d1f73aeed393ef7a52faff)
+- Replaced the quick switcher's button icon with something more appropriate. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/5093d25eb39359dbd93bfb816d4d31597c68adb3)
+
+### Fixed
+
+- Fixed an issue where native window controls fucked with the native window title bar setting, causing a window's content to never spawn. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/46d8c266ff6de432dbe4aeac89157a1de026063c)
+- `openUrlExternally` only allows known URL schemes. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/c5ebb2214287d8f8081771ff6c785b6e24c55023)
+- Fixed proportions for non-1:1 HQ profile pics in the profile viewer's match report. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/cd90acb8744a278109a9028504d28ce477f136be)
+
+### Development
+
+- Added a CI job for typechecking. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/345836edda9ed756ad81aba9817ad685dcf08336)
+
+### Merged Pull Requests
+
+- https://github.com/Fchat-Horizon/Horizon/pull/920 by @freenutsxd
+- https://github.com/Fchat-Horizon/Horizon/pull/936 by @freenutsxd
+
+Non PR'd changes by @CodingWithAnxiety and @FatCatClient
+
+## [2.4.3] - 2026-10-20
+
+### Fixed
+
+- 'Dullahan' profiles are properly categorized as fae now, instead of being considered equine. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/b8644abf89e9e72868e08c054a701b1c9cb9f0ec)
+
+### Development
+
+- Upgraded to Electron 43.8.1 to solve an upstream security issue in Chromium. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/58287f7410b422da3e5a2809fe1158d5ea255e40)
+ - Read more [here](https://emudhra.we-have-a-problem.com/).
+
+## [2.4.2] - 2026-10-18
+
+### Added
+
+- Windows and Linux versions of Horizon now come with a proper, standalone portable release that does not require installation and keeps its data in a nearby folder. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/83618090439212d164ff695a18e8e70f51eb65b2)
+
+### Changed
+
+- Redesigned the status history menu to fix some jank and inconsistencies: [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/3ff0e70ba6983227c7d0e26cdb87694cddf7062f)
+ - Statuses that are pinned are no longer also shown in the history list
+ - Pinning a status no longer selects it as well, bc that's kinda dumb. Also, tooltips were added for those toggles
+ - You can't click "Select" with nothing selected anymore
+ - It just looks prettier :)
+- The window control buttons on Windows and Linux now use your system's native look and features. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/4dcab575a42ed3faa2108b272548d8efae37bbe4)
+- Clicking the 'Check for updates' option in the app menu now tells you if there is no new update, instead of leaving you sad and alone with no answer. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/0a8b9f82299e7f485d1801edad9dc09c91b3b825)
+- Some receiving/ giving kinks-- like Storytelling (Player)/ Storyteller (Narrator), now properly count their opposite versions as a match instead of trying to match two players together. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/25b84147b1be60ca14ff324ec4b29d376fd793ea)
+
+### Fixed
+
+- Fixed a regression where network-related disconnects would no longer attempt to automatically reconnect you. [[Commit]](https://github.com/Fchat-Horizon/Horizon/pull/985/changes/99f23494b24e88d45d6048423d3ed20b38190ac7)
+- Fixed the first conversation you open in a session (the console) not being considered part of the navigation history when using your mouse's forward and backward buttons. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/daa853efeca66888d378e7391fa52d97ca5467f4)
+- Fixed issues where opening a new tab right after deleting a character on the website would keep that character in your list. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/d27b234adb957c62f276013c91b1617bcbec176d)
+
+### Development
+
+- Upgraded to Electron 43 (`v43.7.6`) from `42.4.1`. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/fb0cf59b5bd2ea5ff4beeab930fbfefc016ae1b7)
+- MacOS CI builds now ship a .dmg instead of a double .zip file. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/c0bdde3a9f58edbac0d46281f425afcfa40a77e0)
+- Various kink and matcher values now use their enum values instead of equivalent magic number values. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/ec822e98c7b8fc11f0feb7bb6c6a8814c98bc2f0)
+
+### Merged Pull Requests
+
+- https://github.com/Fchat-Horizon/Horizon/pull/915 by @snowsune
+- https://github.com/Fchat-Horizon/Horizon/pull/922 by @freenutsxd
+- https://github.com/Fchat-Horizon/Horizon/pull/944 by @FatCatClient
+- https://github.com/Fchat-Horizon/Horizon/pull/949 by @snowsune
+- https://github.com/Fchat-Horizon/Horizon/pull/971 by @FatCatClient
+- https://github.com/Fchat-Horizon/Horizon/pull/977 by @FatCatClient
+- https://github.com/Fchat-Horizon/Horizon/pull/985 by @freenutsxd
+- https://github.com/Fchat-Horizon/Horizon/pull/989 by @CodingWithAnxiety
+
+## [2.4.1] - 2026-10-06
+
+### Changed
+
+- A whole bunch of performance improvements related to user lists, the profile cache, and smart filters. _This includes the problem(s) with 2.4.0 where switching channels would cause a noticeable delay before scrolling down._
+ - Fixed issues where the filter icon (for Smart Filters) would cause UserView components to render slowly, even if you didn't have smart filters enabled at all. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/b6a967c8bd7ad7fae2a6738dc930c3260f80974f)
+ - Channel members and the 'All friends/ bookmark' tabs are now virtualized. [[Channels]](https://github.com/Fchat-Horizon/Horizon/commit/4412e32785c6a9e1294de728c95126e19a41ae75) [[Friends]](https://github.com/Fchat-Horizon/Horizon/commit/b7db4ba9bd629f4ce84782c143432f0295c2480f)
+ - This should solve noticable hitches when opening the members list of large channels.
+ - Switching between channels now purges a character from the profile fetch queue, if it's no longer needed. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/595a2f5b89126a14095b19a473bee44bc7b9f8b9)
+ - Overall, things like switching between channels and tabs in the right sidebar should feel a lot snappier-- Even snappier than before!
+- Various emoji and other phrases are now used to check if certain age-related preferences or smart filter options are triggered. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/255f92408e7266448ae5c5adaeba395105be44f8)
+- Added some tips to the Solstice sync tool, with common network issues that might provide problems. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/4e35b838f3026032ec12c855d69c726e47a74737)
+- Updated localization files. [[Spanish]](https://github.com/Fchat-Horizon/Horizon/commit/e42f989b2a9d392e96f656671bd83bf43a6b28ec) [[French]](https://github.com/Fchat-Horizon/Horizon/commit/a8b89c59751ccee470435fdf1f1a7eb752d888e6) [[Italian]](https://github.com/Fchat-Horizon/Horizon/commit/67616e20024a66388f7001e22045beff84783f14) [[Hungarian]](https://github.com/Fchat-Horizon/Horizon/commit/7240f24b85611578b0508f47422e6400cc5444d3)
+
+### Fixed
+
+- The F-List API ticket is now shared between your Horizon tabs, instead of each tab having its own. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/e99fb640d0ef379839cd66a7d8c367cd215aa2b0)
+ - This should solve almost every case where you'd get an "Invalid ticket" error when connecting in a second or third tab. Except for when you're also connected on a different device. That's an issue with F-List that we cannot do anything about.
+- Disconnecting no longer keeps channels you have left stuck in your navigation history. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/540a9872c52e76894d036be396f60c9b16ce846b)
+ - This should solve issues where rapidly disconnecting after a reconnect would keep "ghost" channels in your history when using the mouse forward/ backward buttons to navigate.
+- Selecting different languages for the spell checker no longer requires a full restart. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/1d3c1d441aae397199d813cfec203dbaf59181f5)
+
+### Development
+
+- Renamed instances of `hasNew` (the unread message counters for conversations and tabs) to `newCount`. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/00012d0e4fcc5200b786b85d5eb1afcea26bafa1)
+
+### Merged Pull Requests
+
+- https://github.com/Fchat-Horizon/Horizon/pull/921 by @freenutsxd
+- https://github.com/Fchat-Horizon/Horizon/pull/924 by @freenutsxd
+- https://github.com/Fchat-Horizon/Horizon/pull/967 by @FatCatClient
+- https://github.com/Fchat-Horizon/Horizon/pull/979 by @freenutsxd
+
## [2.4.0] - 2026-10-02
### Added
-- Log syncing with Solstice! Simply go to the app menu, and click "Manage Data" to find the "Sync with Solstice" option. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/c16f331d47cad189f891f1b1eabbb6347d4856a8)
+- Log syncing with Solstice! Simply go to the app menu, and click "Manage Data" to find the "Device sync" option. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/c16f331d47cad189f891f1b1eabbb6347d4856a8)
- You'll need Solstice version 2026.9.0 or later to access its log sync function.
- Added a toggle to open every clicked link in Incognito Mode. It's under 'Advanced' in the app settings menu. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/76c0a2b5e622abddec2fc3ef58cbf3a67b485ccd)
+ - This feature is unavailable on macOS releases of Horizon, because Safari does not support opening links in Private Mode through CLI arguments.
### Changed
@@ -22,9 +191,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Counted things ("5 messages", "1 channel") now use the correct plural forms for your display language, including languages with more than two plural forms like Russian. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/591a635260cbdb89b270de002bcdc8d018aaaec1)
- The green "Update ready" button now stops nagging you after 5 animation cycles. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/431f05447ec8bd4fb9034807a899934680c9bbfd)
- MacOS versions no longer use a tray icon and its functionality is now inside the dock icon's context menu. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/9ac4d7dd5b025209105ea451304c6fa515dbd5c2)
-- Changing your log directory now restarts the app instead of shutting it down. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/3180593704853b47b9faaa0834a484bacb3c0145)
+- Changing your log directory now prompts you to restart the app instead of shutting it down. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/3180593704853b47b9faaa0834a484bacb3c0145)
- Places with character select dropdowns (like on the profile viewer and friends dialog) now show the selected character's avatar. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/2f1c47fe2a692835b7df6d36122de482c57804e0)
-- Lamia and naga are not marked as the same species in the hover preview. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/6ed5a54d0c5bc3bc7c131c855bafc3795241aa91)
+- Lamia and naga are no longer marked as the same species in the hover preview. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/6ed5a54d0c5bc3bc7c131c855bafc3795241aa91)
### Fixed
@@ -38,14 +207,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed various buttons not using proper 4.7:1 contrast ratios for their text. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/5291c3fb5da76bb24fda2eb737ce6900870f7ec3)
- Guestbooks with more than 15 posts now show an accurate number. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/13d97021e5f914a376119e006913da1e8376a268)
- Toggling the HQ Profile Pics setting now works without needing to restart. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/29d9ab752442ba250319651a9ed2378cb2defc59)
-- Having 'Standardize profile fonts' no longer fucks up BBCode parsing if a profile uses characters that are parsed into `[` or `]`. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/a8776a12d983904476f6e1cc4ad2447b1ae67438)
+- Having 'Standardize profile fonts' no longer fucks up BBCode parsing if a profile uses characters that are parsed into `[` or `]`, like Japanese quotation marks. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/a8776a12d983904476f6e1cc4ad2447b1ae67438)
- This also makes it so that emoji are now properly visible (even if they aren't technically ASCII characters).
- Scrolling upwards in the log viewer while searching now loads older messages better, without you needing to scroll upward more than once. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/425dfa03350cf33606bbf03b364d24cc609b522f)
- The tab line no longer slightly overlaps with the window content if you resize the window and then switch tabs. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/ba681aa34d10bd0dfcb38a2321817f6897457b49)
- Large backup .zip exports now stream to disk, to fix memory overconsumption during the export process. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/5a807cb2fff283e603e9dd6c7d85f80c56663369)
-- Sending a friend request from the profile viewer no longer incorrectly shows you as that person's friend, instead of showing your request as "Pending". [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/ae009dc8155ddb5346be4cfcc6a26bdc7788b53f)
+- Sending a friend request from the profile viewer no longer incorrectly shows you as friends already, even if the request is still pending. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/ae009dc8155ddb5346be4cfcc6a26bdc7788b53f)
- The mobile quick switcher on top now respects channel group orders. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/23c4beb648f692758d4207bca05ee52c41c91b75)
-- Names in the "All Friends" tab to the right are no longer all green if you have "Color friends/ bookmarks in a different color" enabled. Obviously, those are your friends and bookmarks. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/b3036a275e5629c2418b9e305e56226bafd45153)
+- Names in the "All Friends" tab to the right are no longer all green if you have "Color friends/ bookmarks in a different color" enabled. Obviously those are your friends and bookmarks. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/b3036a275e5629c2418b9e305e56226bafd45153)
- Fixed screen reader and tooltip labels for the public and private channels always claiming a channel is official. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/93b48b418d55d2abb2841fe51da9cc24ecdb53ef)
- Did **not** fix using `/roll` commands with `NaN` values :^) [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/48a809125f130f0240bf0086a9412b673dc76f09)
@@ -65,8 +234,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The dev-only "Test Language" is now a readable pseudo-locale: accented characters and length padding make untranslated strings and too-tight layouts easy to spot. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/15e2e530c0ff0b89fd98e414a20da56a14122079)
- Locale codes are now valid BCP47 (`en-US`, `en-x-uwu`, `en-x-pseudo`); previously saved display language settings are migrated automatically. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/a3c2d2ec54d379aa6a262e8654d9123a3f133113)
- Removed locale keys that were no longer referenced anywhere. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/9033b8422904ae53e387fd82583558eb6f642d8f)
-- The Nix flake now automatically fixes its dependency hash based on PNPM updates. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/50d7ed33e569f47af72a4b1f4b5030d68d3033d9)
-- Fixed issues with Dev tools not opening properly for the window and image preview web instance. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/455733015f730bee0989073885847435a9cf2a9e)
+- The Nix flake now automatically fixes its dependency hash based on pnpm updates. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/50d7ed33e569f47af72a4b1f4b5030d68d3033d9)
+- Fixed issues with Dev tools not opening properly for the main window and image preview web instances. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/455733015f730bee0989073885847435a9cf2a9e)
- Switched to using an API key for CI/CD Mac notarization. [[Commit]](https://github.com/Fchat-Horizon/Horizon/commit/13fcd7d515638462418b854b5a466ba8cc2991c4)
### Documentation
@@ -1653,7 +1822,16 @@ Non PR'd changes by @CodingWithAnxiety and @FatCatClient
- IOS build removed [[Commit](https://github.com/Fchat-Horizon/Horizon/commit/41261d1ba7043eb7dfd5a1a6331dc604ff338814)]
- Webchat removed [[Commit](https://github.com/Fchat-Horizon/Horizon/commit/b894a180b9be31f68d1458aaa3c59f9c4470da89)]
-[Unreleased]: https://github.com/Fchat-Horizon/Horizon/compare/v2.4.0...development
+[Unreleased]: https://github.com/Fchat-Horizon/Horizon/compare/v2.5.0-beta.0...development
+[2.5.0-beta.0]: https://github.com/Fchat-Horizon/Horizon/compare/v2.4.5...v2.5.0-beta.0
+[2.4.5]: https://github.com/Fchat-Horizon/Horizon/compare/v2.4.4...v2.4.5
+[2.4.4]: https://github.com/Fchat-Horizon/Horizon/compare/v2.4.3...v2.4.4
+[2.4.3]: https://github.com/Fchat-Horizon/Horizon/compare/v2.4.2...v2.4.3
+[2.4.2]: https://github.com/Fchat-Horizon/Horizon/compare/v2.4.2-beta.2...v2.4.2
+[2.4.2-beta.2]: https://github.com/Fchat-Horizon/Horizon/compare/v2.4.2-beta.1...v2.4.2-beta.2
+[2.4.2-beta.1]: https://github.com/Fchat-Horizon/Horizon/compare/v2.4.2-beta.0...v2.4.2-beta.1
+[2.4.2-beta.0]: https://github.com/Fchat-Horizon/Horizon/compare/v2.4.1...v2.4.2-beta.0
+[2.4.1]: https://github.com/Fchat-Horizon/Horizon/compare/v2.4.0...v2.4.1
[2.4.0]: https://github.com/Fchat-Horizon/Horizon/compare/v2.4.0-beta.3...v2.4.0
[2.4.0-beta.3]: https://github.com/Fchat-Horizon/Horizon/compare/v2.4.0-beta.2...v2.4.0-beta.3
[2.4.0-beta.2]: https://github.com/Fchat-Horizon/Horizon/compare/v2.4.0-beta.1...v2.4.0-beta.2
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0dc999c7..ee0ef5dd 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -38,6 +38,8 @@ Solstice is written primarily in _Vue_, _Typescript_, and _Javascript_, wrapped
For Android builds you'll additionally need **JDK 17+** and the **Android SDK** (API 35 platform). [Android Studio](https://developer.android.com/studio) is the easiest way to get the SDK.
+You should use Node.js **v24.22.1** (see [.nvmrc](./.nvmrc)).
+
### Setting up your development enviroment
In short, you can run the following commands:
diff --git a/assets/fonts/HorizonIcons.woff2 b/assets/fonts/HorizonIcons.woff2
new file mode 100644
index 00000000..1e947636
Binary files /dev/null and b/assets/fonts/HorizonIcons.woff2 differ
diff --git a/bbcode/EIconSelector.vue b/bbcode/EIconSelector.vue
index 27332005..fdf66c7e 100644
--- a/bbcode/EIconSelector.vue
+++ b/bbcode/EIconSelector.vue
@@ -20,72 +20,56 @@
>
+
+
@@ -493,13 +493,32 @@
line-height: 1;
z-index: 1000;
- &.big {
- min-height: 530px;
+ .modal-body {
+ display: flex;
+ min-height: 0;
+
+ &,
+ &:hover {
+ overflow-y: hidden;
+ }
}
.eicon-selector-ui {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+
+ > div:not(.loading) {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ }
+
.search-bar {
display: flex;
+ flex-shrink: 0;
.search {
flex: 1;
@@ -521,19 +540,17 @@
}
}
- .courtesy {
- position: absolute;
- bottom: 7px;
+ .eicon-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-shrink: 0;
font-size: 9px;
- right: 1rem;
- opacity: 50%;
- }
+ padding-top: 6px;
- .upload {
- position: absolute;
- bottom: 7px;
- font-size: 9px;
- left: 1rem;
+ .courtesy {
+ opacity: 50%;
+ }
}
.results {
@@ -630,24 +647,37 @@
}
&.big {
- min-height: 530px;
width: 590px;
max-width: 590px;
.eicon-selector-ui {
.carousel.results {
- max-height: unset;
- height: 535px;
- margin-bottom: 0.75rem;
+ // no fixed height so that it can shrink with the window.
+ // max-height keeps the usual size on a tall one.
+ flex: 0 1 535px;
+ min-height: 0;
+ max-height: 535px;
.carousel-inner {
overflow-x: hidden;
overflow-y: scroll;
height: 100%;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(77px, 1fr));
+ // auto rows shrink to fit the height because of the global min-height: 0
+ grid-auto-rows: max-content;
+ align-content: start;
+
+ &::after {
+ display: none;
+ }
}
.carousel-item {
- display: inline-block;
+ display: block;
+ width: auto;
+ margin: 0;
+ justify-self: center;
}
}
}
diff --git a/bbcode/Editor.vue b/bbcode/Editor.vue
index 7736157b..6e450fdd 100644
--- a/bbcode/Editor.vue
+++ b/bbcode/Editor.vue
@@ -1,6 +1,11 @@
+
+ Aa
+
+
+
@@ -104,6 +127,9 @@
>
+
+
+
-
-
+
{{ error }}
{{ l('chat.disconnected') }}
+
+
+ {{ l('settings.migration.prompt', { title: l('title') }) }}
+
SimpleCharacter[], required: true },
defaultCharacter: { type: Number, required: true },
@@ -492,14 +519,21 @@
character: core.connection.character
});
+ // Check if settings migration is needed
+ if (core.state.needsSettingsMigration) {
+ (this.$refs['settingsMigration'] as InstanceType).show(
+ true
+ );
+ }
+
// tslint:disable-next-line:no-floating-promises
core.siteSession.onConnectionEstablished();
});
core.watch(
- () => core.conversations.hasNew,
- hasNew => {
+ () => core.conversations.newCount,
+ newCount => {
document.title =
- (hasNew ? '💬 ' : '') +
+ (newCount ? '💬 ' : '') +
l(core.connection.isOpen ? 'title.connected' : 'title', {
character: core.connection.character
});
@@ -533,6 +567,10 @@
this.selectedCharacter = character;
},
+ async migrateSettings(useCurrentAsGlobal: boolean): Promise {
+ await core.migrateToGlobalSettings(useCurrentAsGlobal);
+ (this.$refs['settingsMigration'] as InstanceType).hide();
+ },
handleCharacterDoubleClick(character: SimpleCharacter): void {
this.selectCharacter(character);
//better safe than sorry :^)
diff --git a/chat/ChatView.vue b/chat/ChatView.vue
index 42eb3cf0..944832c3 100644
--- a/chat/ChatView.vue
+++ b/chat/ChatView.vue
@@ -78,7 +78,7 @@
role="button"
class="userInfo-button-item btn btn-outline-secondary"
:title="l('settings.character')"
- @click.prevent="showSettings()"
+ @click.prevent="openCharacterSettings()"
>
@@ -142,7 +142,7 @@
@click.prevent="showQuickJump()"
:title="l('quickJump.action')"
class="btn btn-link"
- >
@@ -401,7 +401,6 @@
-
this.$refs['settingsDialog']).show();
- },
-
showSearch(): void {
(this.$refs['searchDialog']).show();
},
@@ -1313,9 +1311,6 @@
: unreadClasses[conversation.unread];
},
- isColorblindModeActive(): boolean {
- return core.state.settings.risingColorblindMode;
- },
getImagePreview(): ImagePreview | undefined {
return this.$refs['imagePreview'] as ImagePreview;
},
@@ -1340,6 +1335,10 @@
user-select: none;
}
+ .platform-darwin #conversations {
+ padding-right: 8px;
+ }
+
.bbcode,
.message,
.profile-viewer {
diff --git a/chat/ConversationSettings.vue b/chat/ConversationSettings.vue
index 5596ab27..8e323d63 100644
--- a/chat/ConversationSettings.vue
+++ b/chat/ConversationSettings.vue
@@ -136,8 +136,8 @@
diff --git a/components/SettingsRadio.vue b/components/SettingsRadio.vue
deleted file mode 100644
index d0e7431d..00000000
--- a/components/SettingsRadio.vue
+++ /dev/null
@@ -1,67 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/components/settings/SettingsArray.vue b/components/settings/SettingsArray.vue
new file mode 100644
index 00000000..251df827
--- /dev/null
+++ b/components/settings/SettingsArray.vue
@@ -0,0 +1,68 @@
+
+
+
{{ name }}
+
+
+
+
+
+
+ {{ l('settings.arrayInstructions') }}
+
+
+
+
+
diff --git a/components/settings/SettingsCheckbox.vue b/components/settings/SettingsCheckbox.vue
new file mode 100644
index 00000000..5adc1f27
--- /dev/null
+++ b/components/settings/SettingsCheckbox.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
diff --git a/components/settings/SettingsInput.vue b/components/settings/SettingsInput.vue
new file mode 100644
index 00000000..64dfb9d0
--- /dev/null
+++ b/components/settings/SettingsInput.vue
@@ -0,0 +1,64 @@
+
+
+
+
+
diff --git a/components/settings/SettingsOverride.vue b/components/settings/SettingsOverride.vue
new file mode 100644
index 00000000..9afa3ea5
--- /dev/null
+++ b/components/settings/SettingsOverride.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
{{ label }}
+
+ {{ description }}
+
+
+
+
+
+
+
+
+
diff --git a/components/settings/SettingsRadio.vue b/components/settings/SettingsRadio.vue
new file mode 100644
index 00000000..4c12fd94
--- /dev/null
+++ b/components/settings/SettingsRadio.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
diff --git a/components/settings/SettingsSelectOverride.vue b/components/settings/SettingsSelectOverride.vue
new file mode 100644
index 00000000..dbe14797
--- /dev/null
+++ b/components/settings/SettingsSelectOverride.vue
@@ -0,0 +1,74 @@
+
+
+
+ {{ resolvedFallbackLabel }}
+
+
+ {{ option.label }}
+
+
+
+
+
diff --git a/components/settings/SettingsTriState.vue b/components/settings/SettingsTriState.vue
new file mode 100644
index 00000000..a4f62371
--- /dev/null
+++ b/components/settings/SettingsTriState.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
diff --git a/components/tabs.ts b/components/tabs.ts
index 7d156555..3aedd374 100644
--- a/components/tabs.ts
+++ b/components/tabs.ts
@@ -1,9 +1,62 @@
import { defineComponent, ref, computed, watch, PropType } from 'vue';
import { CreateElement, VNode } from 'vue';
+/**
+ * Tab item interface for regular tabs.
+ * @interface TabTabItem
+ * @property {string} [type] The type of the item, always 'tab' for this interface. Used internally.
+ * @property {string} id The unique identifier for the tab. The tab component uses this to track the opened tab, while its parent is expected to show content dynamically based on this value.
+ * @property {string} label The label text for the tab.
+ * @property {string} [iconClass] Optional CSS class for an icon to display in the tab. Mostly a FontAwesome one.
+ */
+interface TabTabItem {
+ readonly type?: 'tab';
+ readonly id: string;
+ readonly label: string;
+ readonly iconClass?: string;
+}
+
+/**
+ * Tab label item interface.
+ * @interface TabLabelItem
+ * @property {string} type The type of the item, always 'label'. Used internally.
+ * @property {string} label The label text for the tab.
+ * @property {string} [iconClass] Optional CSS class for an icon to display in the tab. Mostly a FontAwesome one.
+ */
+interface TabLabelItem {
+ readonly type: 'label';
+ readonly label: string;
+ readonly iconClass?: string;
+}
+
+/**
+ * Tab divider item interface.
+ * @interface TabDividerItem
+ * @property {string} type The type of the item, always 'divider'. Used internally.
+ */
+interface TabDividerItem {
+ readonly type: 'divider';
+}
+/**
+ * Union type for all possible tab items.
+ * @type {TabItem}
+ * @property {TabTabItem} Tab item.
+ * @property {TabLabelItem} Label item.
+ * @property {TabDividerItem} Divider item.
+ */
+type TabItem = TabTabItem | TabLabelItem | TabDividerItem;
+
+/**
+ * Props interface for the Tabs component.
+ * @interface TabsProps
+ * @property {string} [value] The currently selected tab's id.
+ * @property {ReadonlyArray} [tabs] The array of tab items to display.
+ * @property {boolean} [fullWidth] Whether the tabs should take the full width of the container.
+ * @property {string} [tabClass] Optional CSS class for the tab container.
+ */
interface TabsProps {
value?: string;
- tabs?: { readonly [key: string]: string } | string[];
+ tabs?: ReadonlyArray;
fullWidth?: boolean;
tabClass?: string;
}
@@ -16,10 +69,8 @@ const Tabs = defineComponent({
default: undefined
},
tabs: {
- type: [Object, Array] as PropType<
- { readonly [key: string]: string } | string[]
- >,
- default: () => ({})
+ type: Array as PropType>,
+ default: () => []
},
fullWidth: {
type: Boolean,
@@ -34,23 +85,34 @@ const Tabs = defineComponent({
setup(props: TabsProps, { emit }) {
const internalValue = ref(props.value);
- const children = computed(() => {
- let result: { [key: string]: string };
- const tabs = props.tabs || [];
+ const items = computed>(() => props.tabs || []);
- if (Array.isArray(tabs)) {
- result = {};
- tabs.forEach((tab, index) => {
- result[String(index)] = tab;
- });
- } else {
- result = tabs;
- }
+ const tabIds = computed(() =>
+ items.value.reduce((result, item) => {
+ if (item.type !== 'label' && item.type !== 'divider') {
+ result.push(item.id);
+ }
+ return result;
+ }, [])
+ );
+
+ const isTabActive = (id: string) => internalValue.value === id;
- return result;
- });
+ const getFirstTabId = () => tabIds.value[0];
- const keys = computed(() => Object.keys(children.value));
+ const shouldSelectFirstTab = () =>
+ internalValue.value === undefined ||
+ !tabIds.value.includes(internalValue.value);
+
+ const selectFirstTabIfNeeded = () => {
+ if (shouldSelectFirstTab()) {
+ const firstId = getFirstTabId();
+ if (firstId) {
+ internalValue.value = firstId;
+ emit('input', firstId);
+ }
+ }
+ };
watch(
() => props.value,
@@ -60,18 +122,9 @@ const Tabs = defineComponent({
);
watch(
- [children, () => props.value],
+ [items, () => props.value],
() => {
- if (
- internalValue.value === undefined ||
- children.value[internalValue.value] === undefined
- ) {
- const firstKey = keys.value[0];
- if (firstKey) {
- internalValue.value = firstKey;
- emit('input', firstKey);
- }
- }
+ selectFirstTabIfNeeded();
},
{ immediate: true }
);
@@ -83,14 +136,84 @@ const Tabs = defineComponent({
return {
internalValue,
- children,
- keys,
+ items,
handleTabClick,
fullWidth: computed(() => props.fullWidth),
- tabClass: computed(() => props.tabClass)
+ tabClass: computed(() => props.tabClass),
+ isTabActive
};
},
render(createElement: CreateElement): VNode {
+ const renderItem = (item: TabItem, index: number) => {
+ if (item.type === 'divider') {
+ return createElement('li', {
+ key: `divider-${index}`,
+ staticClass: 'nav-item nav-divider'
+ });
+ }
+
+ if (item.type === 'label') {
+ const labelChildren: Array = [];
+ if (item.iconClass) {
+ labelChildren.push(
+ createElement('i', {
+ class: item.iconClass,
+ style: { marginRight: '0.35rem' }
+ })
+ );
+ }
+ labelChildren.push(item.label);
+
+ return createElement(
+ 'li',
+ { key: `label-${index}`, staticClass: 'nav-item disabled' },
+ [
+ createElement(
+ 'span',
+ {
+ staticClass: 'nav-link disabled',
+ attrs: { 'aria-disabled': 'true' }
+ },
+ labelChildren
+ )
+ ]
+ );
+ }
+
+ const tabChildren: Array = [];
+ if (item.iconClass) {
+ tabChildren.push(
+ createElement('i', {
+ class: item.iconClass,
+ style: { marginRight: '0.35rem' }
+ })
+ );
+ }
+ tabChildren.push(item.label);
+
+ return createElement(
+ 'li',
+ { key: `tab-${item.id}`, staticClass: 'nav-item' },
+ [
+ createElement(
+ 'a',
+ {
+ attrs: { href: '#' },
+ staticClass: 'nav-link',
+ class: { active: this.isTabActive(item.id) },
+ on: {
+ click: (event: MouseEvent) => {
+ event.preventDefault();
+ this.handleTabClick(item.id);
+ }
+ }
+ },
+ tabChildren
+ )
+ ]
+ );
+ };
+
return createElement(
'div',
{
@@ -98,19 +221,8 @@ const Tabs = defineComponent({
},
[
createElement('ul', { staticClass: `nav ${this.tabClass}` }, [
- this.keys.map((key: string) =>
- createElement('li', { staticClass: 'nav-item' }, [
- createElement(
- 'a',
- {
- attrs: { href: '#' },
- staticClass: 'nav-link',
- class: { active: this.internalValue === key },
- on: { click: () => this.handleTabClick(key) }
- },
- [this.children[key]!]
- )
- ])
+ this.items.map((item: TabItem, index: number) =>
+ renderItem(item, index)
),
...(this.fullWidth
? []
diff --git a/electron/About.vue b/electron/About.vue
index 721cc8ff..11df07f4 100644
--- a/electron/About.vue
+++ b/electron/About.vue
@@ -721,6 +721,7 @@
return {
['platform-' + this.platform]: true,
+ horizonHighContrast: this.settings?.horizonHighContrast || false,
bbcodeGlow: this.settings?.horizonBbcodeGlow || false,
disableWindowsHighContrast:
this.settings?.risingDisableWindowsHighContrast || false
@@ -794,9 +795,11 @@
transform: translate(-50%, -50%);
background-image: linear-gradient(
-2deg,
- #1b0f30 5%,
- #a0487e 19%,
- #ffa978 79%
+ #9e3054 5%,
+ #b63f53 19%,
+ #d45c53 50%,
+ #ed9660 75%,
+ #eebe6e 100%
);
z-index: 0;
filter: blur(20px);
diff --git a/electron/Changelog.vue b/electron/Changelog.vue
index debd16ff..8fdf58ea 100644
--- a/electron/Changelog.vue
+++ b/electron/Changelog.vue
@@ -336,6 +336,7 @@
return {
['platform-' + this.platform]: true,
+ horizonHighContrast: this.settings?.horizonHighContrast || false,
bbcodeGlow: this.settings?.horizonBbcodeGlow || false,
disableWindowsHighContrast:
this.settings?.risingDisableWindowsHighContrast || false
diff --git a/electron/Exporter.vue b/electron/Exporter.vue
index ea3f7aac..c77c8853 100644
--- a/electron/Exporter.vue
+++ b/electron/Exporter.vue
@@ -605,7 +605,9 @@
v-model="character.selected"
/>
{{
- character.name
+ character.name === '_'
+ ? l('settings.export.globalSettings')
+ : character.name
}}
{{ l('sync.betaInfo') }}
- {{ l('sync.description') }}
+
+ {{ l('sync.description') }}
+
+
+
+
+ {{ l('sync.connectionHelp') }}
+
+
+ {{ l('sync.connectionHelp.firewall') }}
+ {{ l('sync.connectionHelp.network') }}
+ {{ l('sync.connectionHelp.ipAddress') }}
+ {{ l('sync.connectionHelp.vpn') }}
+
+
+
-
-
diff --git a/electron/Window.vue b/electron/Window.vue
index 96dcce5f..6c9aabdb 100644
--- a/electron/Window.vue
+++ b/electron/Window.vue
@@ -2,6 +2,7 @@
@@ -12,7 +13,6 @@
border-bottom-width: 1px;
min-height: 31px;
`"
- class="border-bottom"
id="window-tabs"
>
@@ -92,7 +92,7 @@
class="nav-link tab"
:class="{
active: tab === activeTab,
- hasNew: tab.hasNew > 0 && tab !== activeTab
+ hasNew: tab.newCount > 0 && tab !== activeTab
}"
>
- {{ tab.hasNew }}
-
-
-
-
-
-
-
-
-
-
@@ -243,7 +230,7 @@
interface Tab {
user: string | undefined;
view: Electron.BrowserView;
- hasNew: number;
+ newCount: number;
avatarUrl?: string;
insertedCssKey?: string;
title: string;
@@ -277,6 +264,13 @@
hideSingleTab: true
};
},
+ watch: {
+ styling(): void {
+ this.$nextTick(() => {
+ this.updateWindowOverlayColors();
+ });
+ }
+ },
computed: {
styling(): string {
try {
@@ -295,6 +289,7 @@
},
async mounted(): Promise {
log.debug('init.window.mounting');
+
// top bar devtools
// browserWindow.webContents.openDevTools({ mode: 'detach' });
@@ -311,6 +306,8 @@
browserWindow.webContents.session.availableSpellCheckerLanguages
);
+ this.updateWindowOverlayColors();
+
log.debug('init.window.languages.supported');
// console.log('MOUNT DICTIONARIES', getSafeLanguages(this.settings.spellcheckLang), this.settings.spellcheckLang);
@@ -374,6 +371,9 @@
electron.ipcRenderer.on('fix-logs', () =>
this.activeTab!.view.webContents.send('fix-logs')
);
+ electron.ipcRenderer.on('settings-menu', () =>
+ this.activeTab!.view.webContents.send('settings-menu')
+ );
electron.ipcRenderer.on('ui-test', () =>
this.activeTab!.view.webContents.send('ui-test')
);
@@ -454,12 +454,12 @@
'disconnect',
(_e: Electron.IpcRendererEvent, id: number) => {
const tab = this.tabMap[id];
- if (tab.hasNew > 0) {
- tab.hasNew = 0;
+ if (tab.newCount > 0) {
+ tab.newCount = 0;
electron.ipcRenderer.send(
- 'has-new',
+ 'new-message-count',
- this.tabs.reduce((cur, t) => cur + t.hasNew, 0),
+ this.tabs.reduce((cur, t) => cur + t.newCount, 0),
this.settings.horizonShowNotificationBadge
);
}
@@ -470,13 +470,13 @@
}
);
electron.ipcRenderer.on(
- 'has-new',
- (_e: Electron.IpcRendererEvent, id: number, hasNew: number) => {
+ 'new-message-count',
+ (_e: Electron.IpcRendererEvent, id: number, newCount: number) => {
const tab = this.tabMap[id];
- tab.hasNew = hasNew;
+ tab.newCount = newCount;
electron.ipcRenderer.send(
- 'has-new',
- this.tabs.reduce((cur, t) => cur + t.hasNew, 0),
+ 'new-message-count',
+ this.tabs.reduce((cur, t) => cur + t.newCount, 0),
this.settings.horizonShowNotificationBadge
);
}
@@ -575,6 +575,7 @@
log.debug('init.window.mounted');
},
+ beforeDestroy(): void {},
methods: {
getSyncedTheme() {
if (!this.settings.themeSync) return this.settings.theme;
@@ -668,7 +669,7 @@
const tab: Tab = {
user: undefined,
view,
- hasNew: 0,
+ newCount: 0,
title: l('title')
};
this.tabs.push(tab);
@@ -739,8 +740,8 @@
return;
this.tabs.splice(this.tabs.indexOf(tab), 1);
electron.ipcRenderer.send(
- 'has-new',
- this.tabs.reduce((cur, t) => cur + t.hasNew, 0),
+ 'new-message-count',
+ this.tabs.reduce((cur, t) => cur + t.newCount, 0),
this.settings.horizonShowNotificationBadge
);
delete this.tabMap[tab.view.webContents.id];
@@ -778,8 +779,7 @@
electron.ipcRenderer.send('install-update', this.updateVersion);
},
openSettingsMenu(): void {
- log.debug('settings clicked');
- electron.ipcRenderer.send('open-settings-menu');
+ this.activeTab!.view.webContents.send('settings-menu');
},
onTabBeforeEnter(el: HTMLElement): void {
if (this.isClosing) return;
@@ -832,6 +832,7 @@
return {
['platform-' + this.platform]: true,
'force-reduced-motion': this.settings?.reducedMotion || false,
+ horizonHighContrast: this.settings?.horizonHighContrast || false,
bbcodeGlow: this.settings?.horizonBbcodeGlow || false,
disableWindowsHighContrast:
this.settings?.risingDisableWindowsHighContrast || false
@@ -842,10 +843,25 @@
};
}
},
+ updateWindowOverlayColors(): void {
+ if (
+ process.platform === 'darwin' ||
+ this.settings.forceNativeWindowControls
+ )
+ return;
+ let color = getComputedStyle(document.body).color;
+ log.debug('window.titlebar.color', color);
+
+ browserWindow.setTitleBarOverlay({
+ color: '#ff000000',
+ symbolColor: color || 'white',
+ height: 32
+ });
+ },
shouldShowNotificationBadge(tab: Tab): boolean {
return (
this.settings.horizonShowWindowAndChatNotificationBadge !== false &&
- tab.hasNew > 0
+ tab.newCount > 0
);
}
}
@@ -872,6 +888,8 @@
}
#window-tabs {
user-select: none;
+ margin-left: env(titlebar-area-x, 0px);
+ width: env(titlebar-area-width, 100%);
.btn {
border: 0;
diff --git a/electron/api-ticket.ts b/electron/api-ticket.ts
new file mode 100644
index 00000000..37a08d17
--- /dev/null
+++ b/electron/api-ticket.ts
@@ -0,0 +1,227 @@
+import Axios from 'axios';
+import { createHash } from 'crypto';
+import * as electron from 'electron';
+import log from 'electron-log'; //tslint:disable-line:match-default-export-name
+import * as qs from 'qs';
+
+export interface ApiTicketResponse {
+ ticket: string;
+ characters: { [key: string]: number };
+ default_character: number;
+ error: string;
+ request?: boolean;
+}
+
+export interface ApiTicketOptions {
+ invalidTicket?: string;
+ fresh?: boolean;
+}
+
+interface CachedTicket extends ApiTicketResponse {
+ issuedAt: number;
+}
+
+// F-List invalidates every previously issued ticket for an account the moment a
+// new one is issued, so every tab should share a single ticket instead of
+// fetching its own. Server-side lifetime is 30 minutes, so we refresh early so an
+// acquired ticket can't expire while a tab is still connecting
+const ticketLifetime = 25 * 60 * 1000;
+
+const cache = new Map();
+const inFlight = new Map>();
+
+async function post(path: string, data: object): Promise {
+ return (
+ await Axios.post(`https://www.f-list.net/json/${path}`, qs.stringify(data))
+ ).data;
+}
+
+// keyed on the credentials so that a wrong/changed password fails correctly
+function credentialKey(account: string, password: string): string {
+ return `${account}:${createHash('sha256').update(password).digest('hex')}`;
+}
+
+function errorResponse(error: string, request?: boolean): ApiTicketResponse {
+ return { ticket: '', characters: {}, default_character: 0, error, request };
+}
+
+async function fetchTicket(
+ key: string,
+ account: string,
+ password: string
+): Promise {
+ log.debug('api.ticket.fetch', { account });
+
+ try {
+ const data = await post & { error: string }>(
+ 'getApiTicket.php',
+ {
+ account,
+ password,
+ no_friends: true,
+ no_bookmarks: true,
+ new_character_list: true
+ }
+ );
+
+ if (data.ticket === undefined) {
+ log.error('error.api.getTicket', { error: data.error });
+ return errorResponse(data.error);
+ }
+
+ const ticket: CachedTicket = {
+ ticket: data.ticket,
+ characters: data.characters ?? {},
+ default_character: data.default_character ?? 0,
+ error: '',
+ issuedAt: Date.now()
+ };
+ cache.set(key, ticket);
+
+ return ticket;
+ } catch (e) {
+ log.error('error.api.getTicket', { error: (e).message });
+ return errorResponse((e).message, true);
+ }
+}
+
+async function lookupId(
+ account: string,
+ ticket: string,
+ name: string
+): Promise {
+ const data = await post<{ id?: number; error: string }>(
+ 'api/character-data.php',
+ { account, ticket, name }
+ );
+ if (data.error === '' && typeof data.id === 'number') return data.id;
+
+ log.error('error.api.characterData', { error: data.error });
+ return undefined;
+}
+
+/**
+ * Refreshes the cached character list without issuing a new ticket.
+ * @returns false if anything failed and a new ticket is needed
+ */
+async function refreshCharacters(
+ account: string,
+ cached: CachedTicket
+): Promise {
+ const { ticket } = cached;
+
+ try {
+ const list = await post<{ characters?: string[]; error: string }>(
+ 'api/character-list.php',
+ { account, ticket }
+ );
+ if (list.error !== '' || !Array.isArray(list.characters)) {
+ log.error('error.api.characterList', { error: list.error });
+ return false;
+ }
+
+ const characters: { [key: string]: number } = {};
+ for (const name of list.characters) {
+ // character-list.php only returns names, look up ids for new or renamed characters
+ const id =
+ cached.characters[name] ?? (await lookupId(account, ticket, name));
+ if (id === undefined) return false;
+ characters[name] = id;
+ }
+
+ cached.characters = characters;
+ return true;
+ } catch (e) {
+ log.error('error.api.characterList', { error: (e).message });
+ return false;
+ }
+}
+
+/**
+ * Returns the account's shared API ticket, fetching a new one only if there isn't a usable one cached.
+ */
+export async function getApiTicket(
+ account: string,
+ password: string,
+ options: ApiTicketOptions = {}
+): Promise {
+ const key = credentialKey(account, password);
+
+ const pending = inFlight.get(key);
+ if (pending !== undefined) return pending;
+
+ const cached = cache.get(key);
+ if (
+ cached !== undefined &&
+ cached.ticket !== options.invalidTicket &&
+ Date.now() - cached.issuedAt < ticketLifetime
+ ) {
+ if (!options.fresh) return cached;
+
+ const refreshed = await refreshCharacters(account, cached);
+ // the ticket may have been replaced while the list refreshed
+ if (cache.get(key) !== cached || inFlight.has(key))
+ return getApiTicket(account, password);
+ if (refreshed) return cached;
+ }
+
+ const request = fetchTicket(key, account, password);
+ inFlight.set(key, request);
+
+ try {
+ return await request;
+ } finally {
+ inFlight.delete(key);
+ }
+}
+
+/** Registers the `get-api-ticket` IPC handler. Main process only. */
+export function registerApiTicketProvider(): void {
+ electron.ipcMain.handle(
+ 'get-api-ticket',
+ async (
+ _e: electron.IpcMainInvokeEvent,
+ account: string,
+ password: string,
+ options?: ApiTicketOptions
+ ) => getApiTicket(account, password, options)
+ );
+}
+
+/** Requests the shared API ticket from the main process. Renderer only.
+ * Throws on network failure like Axios would, so callers can still tell it apart from F-List rejecting the login
+ */
+export async function requestApiTicket(
+ account: string,
+ password: string,
+ options?: ApiTicketOptions
+): Promise {
+ const res = (
+ await electron.ipcRenderer.invoke(
+ 'get-api-ticket',
+ account,
+ password,
+ options
+ )
+ );
+
+ if (res.request) {
+ const error = new Error(res.error);
+ (error).request = true;
+ throw error;
+ }
+
+ return res;
+}
+
+/** Creates a `Connection` ticket provider backed by the shared ticket. Renderer only*/
+export function sharedTicketProvider(
+ account: string,
+ password: string
+): (invalidTicket?: string) => Promise {
+ return async invalidTicket => {
+ const res = await requestApiTicket(account, password, { invalidTicket });
+ if (res.error !== '') throw new Error(res.error);
+ return res.ticket;
+ };
+}
diff --git a/electron/browser_windows.ts b/electron/browser_windows.ts
index c6c6142b..27a7c7bb 100644
--- a/electron/browser_windows.ts
+++ b/electron/browser_windows.ts
@@ -149,21 +149,21 @@ const badges: electron.NativeImage[] = [
];
/**
- * Handles the 'has-new' IPC event.
+ * Handles the 'new-message-count' IPC event.
* This event is triggered when there are new messages in an appplication window's tab(s).
* It updates the dock badge on macOS and applies an overlay icon to all windows on Windows and Linux.
* @event
* @param {IpcMainEvent} e Event reference.
- * @param {number} hasNew The amount of new messages for the window that called it. If hasNew =< 0, the user has no new messages
+ * @param {number} newCount The amount of new messages for the window that called it. If newCount =< 0, the user has no new messages
* @param {boolean} numberedBadges Whether to show the number of new messages in the badge or just a dot indicating that there are new messages. This is used on Windows and Linux, as macOS does not support numbered badges.
*/
electron.ipcMain.on(
- 'has-new',
- (e: IpcMainEvent, hasNew: number, numberedBadges: boolean) => {
- log.debug('app.hasNew', { hasNew, numberedBadges });
+ 'new-message-count',
+ (e: IpcMainEvent, newCount: number, numberedBadges: boolean) => {
+ log.debug('app.newCount', { newCount, numberedBadges });
const window = electron.BrowserWindow.fromWebContents(e.sender);
if (window !== undefined && window !== null) {
- newMessagesMap[window.id] = hasNew;
+ newMessagesMap[window.id] = newCount;
}
updateNotificationBadges(numberedBadges);
}
@@ -355,6 +355,11 @@ export function createMainWindow(
windowProperties.titleBarStyle = 'hiddenInset';
} else {
windowProperties.frame = settings.forceNativeWindowControls;
+ windowProperties.titleBarStyle = settings.forceNativeWindowControls
+ ? 'default'
+ : 'hidden';
+ //we'll set the actual colors in Window.vue, but passing 'true' just enables overlay controls with default controls
+ windowProperties.titleBarOverlay = !settings.forceNativeWindowControls;
}
const window = new electron.BrowserWindow(windowProperties);
@@ -787,74 +792,6 @@ export function closeUpdaterSplash(): void {
updaterSplash = undefined;
}
-/**
- * Creates a new settings window.
- * @function
- * @param {GeneralSettings} settings
- * The general settings for the application, modified by the user in the settings window.
- * @param {ImporterHint} ImporterHint
- * Handles what and how we should import
- * @param {electron.BrowserWindow} parentWindow
- * The parent window for the settings window. This is used to create a modal dialog.
- * @returns {electron.BrowserWindow | undefined}
- * Returns the newly created settings window or undefined if creation failed.
- */
-export function createSettingsWindow(
- settings: GeneralSettings,
- ImporterHint: ImporterHint,
- parentWindow: electron.BrowserWindow
-): electron.BrowserWindow | undefined {
- let desiredHeight = 570;
- let desiredWidth = 885;
-
- const windowProperties: electron.BrowserWindowConstructorOptions = {
- center: true,
- show: false,
- icon: process.platform === 'win32' ? winIcon : pngIcon,
- frame: false,
- width: desiredWidth,
- minWidth: desiredWidth,
- height: desiredHeight,
- minHeight: desiredHeight,
- resizable: true,
- modal: true,
- parent: parentWindow,
- maximizable: false,
- webPreferences: {
- webviewTag: true,
- nodeIntegration: true,
- nodeIntegrationInWorker: true,
- spellcheck: true,
- enableRemoteModule: true,
- contextIsolation: false,
- partition: 'persist:fchat'
- } as any
- };
-
- if (process.platform === 'darwin') {
- windowProperties.titleBarStyle = 'hiddenInset';
- } else if (settings.forceNativeWindowControls) {
- windowProperties.frame = true;
- windowProperties.minimizable = false;
- windowProperties.maximizable = false;
- windowProperties.autoHideMenuBar = true;
- }
- const browserWindow = new electron.BrowserWindow(windowProperties);
- remoteMain.enable(browserWindow.webContents);
- browserWindow.loadFile(path.join(__dirname, 'settings.html'), {
- query: {
- settings: JSON.stringify(settings),
- import: ImporterHint === 'none' ? '' : ImporterHint
- }
- });
-
- browserWindow.once('ready-to-show', () => {
- browserWindow.show();
- });
-
- return browserWindow;
-}
-
/**
* Creates and configures a modal changelog window as a child of the specified parent Electron BrowserWindow.
*
diff --git a/electron/build/build.mjs b/electron/build/build.mjs
index 77fee612..3e1e753d 100644
--- a/electron/build/build.mjs
+++ b/electron/build/build.mjs
@@ -51,7 +51,7 @@ const OS_CONFIGS = {
},
windows: {
aliases: ['win', 'win32', 'win64'],
- formats: ['exe', 'msi', 'nsis', 'portable'],
+ formats: ['exe', 'msi', 'nsis', 'portable', 'dir'],
arches: ['x64', 'ia32', 'arm64']
}
};
@@ -80,7 +80,7 @@ Examples:
Supported formats by OS:
macOS: dmg, zip, dir
Linux: deb, rpm, tar.gz, AppImage
- Windows: exe, msi, nsis, portable
+ Windows: exe, msi, nsis, portable, dir
Supported architectures by OS:
macOS: x64, arm64, universal
diff --git a/electron/build/disk.icns b/electron/build/disk.icns
index 4c3fbaba..26ed11e5 100644
Binary files a/electron/build/disk.icns and b/electron/build/disk.icns differ
diff --git a/electron/build/icon.ico b/electron/build/icon.ico
index 3337798f..52ac2911 100755
Binary files a/electron/build/icon.ico and b/electron/build/icon.ico differ
diff --git a/electron/build/icon.icon/Assets/square.svg b/electron/build/icon.icon/Assets/square.svg
new file mode 100644
index 00000000..dc329638
--- /dev/null
+++ b/electron/build/icon.icon/Assets/square.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/electron/build/icon.icon/icon.json b/electron/build/icon.icon/icon.json
index c2eb5040..1d24586c 100644
--- a/electron/build/icon.icon/icon.json
+++ b/electron/build/icon.icon/icon.json
@@ -2,7 +2,7 @@
"fill": {
"linear-gradient": [
"display-p3:0.95294,0.74369,0.57953,1.00000",
- "display-p3:0.38403,0.28804,0.32936,1.00000"
+ "display-p3:0.38403,0.11481,0.31974,1.00000"
]
},
"groups": [
@@ -41,7 +41,10 @@
{
"appearance": "dark",
"value": {
- "automatic-gradient": "display-p3:0.89893,0.62793,0.50586,1.00000"
+ "linear-gradient": [
+ "display-p3:0.96036,0.73272,0.63018,1.00000",
+ "display-p3:0.89893,0.41239,0.40128,1.00000"
+ ]
}
},
{
@@ -130,6 +133,55 @@
"blur-material": 0.5,
"hidden": false,
"layers": [
+ {
+ "blend-mode": "hard-light",
+ "fill": {
+ "linear-gradient": [
+ "display-p3:1.00000,0.62569,0.53757,0.44271",
+ "display-p3:0.35562,0.09344,0.23442,0.68666"
+ ]
+ },
+ "glass": false,
+ "hidden-specializations": [
+ {
+ "value": true
+ },
+ {
+ "idiom": "macOS",
+ "value": false
+ }
+ ],
+ "image-name": "square.svg",
+ "name": "square",
+ "opacity-specializations": [
+ {
+ "value": 1
+ },
+ {
+ "appearance": "dark",
+ "value": 0
+ },
+ {
+ "appearance": "tinted",
+ "value": 0
+ }
+ ],
+ "position-specializations": [
+ {
+ "value": {
+ "scale": 1,
+ "translation-in-points": [-368.3984375, -278.84375]
+ }
+ },
+ {
+ "idiom": "macOS",
+ "value": {
+ "scale": 3,
+ "translation-in-points": [0, 0]
+ }
+ }
+ ]
+ },
{
"fill-specializations": [
{
@@ -175,6 +227,12 @@
}
},
{
+ "blend-mode-specializations": [
+ {
+ "appearance": "tinted",
+ "value": "plus-darker"
+ }
+ ],
"fill-specializations": [
{
"value": {
@@ -219,6 +277,12 @@
}
},
{
+ "blend-mode-specializations": [
+ {
+ "appearance": "tinted",
+ "value": "normal"
+ }
+ ],
"fill-specializations": [
{
"value": {
@@ -267,6 +331,12 @@
}
},
{
+ "blend-mode-specializations": [
+ {
+ "appearance": "tinted",
+ "value": "plus-darker"
+ }
+ ],
"fill-specializations": [
{
"value": {
@@ -378,6 +448,7 @@
}
],
"supported-platforms": {
+ "circles": ["watchOS"],
"squares": ["macOS"]
}
}
diff --git a/electron/build/icon.png b/electron/build/icon.png
index 6dfd264d..398c6e57 100755
Binary files a/electron/build/icon.png and b/electron/build/icon.png differ
diff --git a/electron/build/portable-readme.txt b/electron/build/portable-readme.txt
new file mode 100644
index 00000000..6d294f8b
--- /dev/null
+++ b/electron/build/portable-readme.txt
@@ -0,0 +1,5 @@
+Horizon runs in portable mode while this folder sits next to it.
+All settings, logs and caches are stored here instead of your user profile.
+
+To move to a new version by hand, put this folder next to the new copy.
+To go back to the normal data location, delete or rename this folder.
diff --git a/electron/chat.ts b/electron/chat.ts
index cc7c6d10..d7c15270 100644
--- a/electron/chat.ts
+++ b/electron/chat.ts
@@ -57,6 +57,7 @@ import l, { setLanguage } from '../chat/localize';
// import {setupRaven} from '../chat/vue-raven';
import Socket from '../chat/WebSocket';
import Connection from '../fchat/connection';
+import core from '../chat/core';
import { Keys } from '../keys';
import { GeneralSettings /*, nativeRequire*/ } from './common';
import { Logs, SettingsStore } from './filesystem';
@@ -66,6 +67,7 @@ import Index from './Index.vue';
import log from 'electron-log'; // tslint:disable-line: match-default-export-name
import { WordPosSearch } from '../learn/dictionary/word-pos-search';
import { MenuItemConstructorOptions } from 'electron/main';
+import ElectronPlatformService from './platform';
log.debug('init.chat');
@@ -389,17 +391,42 @@ const connection = new Connection(
remote.app.getVersion(),
Socket
);
-initCore(connection, settings, Logs, SettingsStore, Notifications);
+const platform = new ElectronPlatformService();
+async function startChat(): Promise {
+ await initCore(
+ connection,
+ settings,
+ Logs,
+ SettingsStore,
+ Notifications,
+ platform
+ );
-log.debug('init.chat.vue');
+ electron.ipcRenderer.on('reload-settings', () => {
+ void core
+ .reloadSettings()
+ .then(() => {
+ EventBus.$emit('configuration-update', core.state.settings);
+ })
+ .catch(err => {
+ log.error('settings.reload.error', err);
+ });
+ });
-//tslint:disable-next-line:no-unused-expression
-new Index({
- el: '#app',
- data: {
- settings,
- hasCompletedUpgrades: JSON.parse(params['hasCompletedUpgrades']!)
- }
-});
+ log.debug('init.chat.vue');
-log.debug('init.chat.vue.done');
+ //tslint:disable-next-line:no-unused-expression
+ new Index({
+ el: '#app',
+ data: {
+ settings,
+ hasCompletedUpgrades: JSON.parse(params['hasCompletedUpgrades']!)
+ }
+ });
+
+ log.debug('init.chat.vue.done');
+}
+
+void startChat().catch(err => {
+ log.error('core.init.error', err);
+});
diff --git a/electron/common.ts b/electron/common.ts
index 4d57c0f7..2bbf6909 100644
--- a/electron/common.ts
+++ b/electron/common.ts
@@ -1,6 +1,10 @@
import * as electron from 'electron';
import * as path from 'path';
import { ProfileViewerGalleryType } from '../site/utils';
+import {
+ GeneralSettings as GeneralSettingsInterface,
+ ColorblindMode
+} from '../chat/general-settings';
import log from 'electron-log'; //tslint:disable-line:match-default-export-name
@@ -23,7 +27,7 @@ function getDefaultLanguage(): string {
}
}
-export class GeneralSettings {
+export class GeneralSettings implements GeneralSettingsInterface {
account = '';
closeToTray = true;
profileViewer = true;
@@ -49,6 +53,8 @@ export class GeneralSettings {
customDictionary: string[] = [];
hwAcceleration = true;
reducedMotion = false;
+ horizonHighContrast = false;
+ horizonColorblindMode: ColorblindMode = 'none';
risingCacheExpiryDays = 30;
horizonCacheMemoryCount = 350;
risingSystemLogLevel: log.LevelOption = 'info';
diff --git a/electron/filesystem.ts b/electron/filesystem.ts
index 2878074e..c0b6028d 100644
--- a/electron/filesystem.ts
+++ b/electron/filesystem.ts
@@ -3,7 +3,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { promisify } from 'util';
import { Message as MessageImpl } from '../chat/common';
-import core from '../chat/core';
+import core, { GLOBAL_SETTINGS_CHARACTER } from '../chat/core';
import {
Character,
Conversation,
@@ -11,15 +11,8 @@ import {
Settings
} from '../chat/interfaces';
import l from '../chat/localize';
-import { GeneralSettings } from './common';
import { isFilesystemArtifact } from './services/log-backup';
-declare module '../chat/interfaces' {
- interface State {
- generalSettings?: GeneralSettings;
- }
-}
-
const dayMs = 86400000;
const read = promisify(fs.read);
@@ -267,6 +260,7 @@ export class Logs implements Logging {
constructor() {
core.connection.onEvent('connecting', () => {
this.index = loadIndex(core.connection.character);
+ this.loadedCharacter = undefined;
});
}
@@ -300,10 +294,10 @@ export class Logs implements Logging {
}
private getIndex(name: string): Index {
+ if (name === core.connection.character) return this.index;
if (this.loadedCharacter === name) return this.loadedIndex!;
this.loadedCharacter = name;
- return (this.loadedIndex =
- name === core.connection.character ? this.index : loadIndex(name));
+ return (this.loadedIndex = loadIndex(name));
}
async getLogDates(
@@ -393,7 +387,7 @@ export class Logs implements Logging {
fs.mkdirSync(baseDir, { recursive: true });
return fs
.readdirSync(baseDir)
- .filter(x => fs.statSync(path.join(baseDir, x)).isDirectory());
+ .filter(x => isValidCharacterDirectory(baseDir, x));
}
}
@@ -430,18 +424,29 @@ export class SettingsStore implements Settings.Store {
const baseDir = core.state.generalSettings!.logDirectory;
return fs
.readdirSync(baseDir)
- .filter(x => fs.statSync(path.join(baseDir, x)).isDirectory());
+ .filter(x => isValidCharacterDirectory(baseDir, x));
}
//tslint:disable-next-line:no-async-without-await
async set(
key: K,
- value: Settings.Keys[K]
+ value: Settings.Keys[K],
+ character?: string
): Promise {
- writeFile(path.join(getSettingsDir(), key), JSON.stringify(value));
+ writeFile(path.join(getSettingsDir(character), key), JSON.stringify(value));
}
}
+function isValidCharacterDirectory(
+ baseDir: string,
+ character: string
+): boolean {
+ return (
+ character !== GLOBAL_SETTINGS_CHARACTER &&
+ fs.statSync(path.join(baseDir, character)).isDirectory()
+ );
+}
+
/**
* Directly fetch the previously saved drafts from disk and return the resulting object to the cache.
* @function
diff --git a/electron/language.ts b/electron/language.ts
index 3aa8820d..5eec1f85 100644
--- a/electron/language.ts
+++ b/electron/language.ts
@@ -1,4 +1,7 @@
import * as _ from 'lodash';
+import { formatLanguage, knownLanguageNames } from '../chat/languages';
+
+export { formatLanguage, knownLanguageNames };
export function getSafeLanguages(
langs: string | string[] | undefined
@@ -25,66 +28,3 @@ let supportedLanguages: string[] = [];
export function updateSupportedLanguages(langs: string[]): void {
supportedLanguages = langs;
}
-
-export const knownLanguageNames = {
- af: 'Afrikaans',
- bg: 'Bulgarian',
- ca: 'Catalan',
- cs: 'Czech',
- cy: 'Welsh',
- da: 'Danish',
- de: 'German, Generic',
- 'de-DE': 'German',
- el: 'Greek',
-
- 'en-AU': 'English, Australian',
- 'en-CA': 'English, Canadian',
- 'en-GB': 'English, British',
- 'en-GB-oxendict': 'English, British, Oxford Spelling',
- 'en-US': 'English, American',
-
- es: 'Spanish',
- 'es-419': 'Spanish, Latin America and Caribbean',
- 'es-AR': 'Spanish, Argentine',
- 'es-ES': 'Spanish, Castilian',
- 'es-MX': 'Spanish, Mexican',
- 'es-US': 'Spanish, American',
-
- et: 'Estonian',
- fa: 'Persian',
- fo: 'Faroese',
- fr: 'French, Generic',
- 'fr-FR': 'French',
- he: 'Hebrew',
- hi: 'Hindi',
- hr: 'Croatian',
- hu: 'Hungarian',
- hy: 'Armenian',
- id: 'Indonesian',
- it: 'Italian, Generic',
- 'it-IT': 'Italian',
- ko: 'Korean',
- lt: 'Lithuanian',
- lv: 'Latvian',
- nb: 'Norwegian',
- nl: 'Dutch',
- pl: 'Polish',
-
- 'pt-BR': 'Portuguese, Brazilian',
- 'pt-PT': 'Portuguese, European',
- pt: 'Portuguese, Generic',
-
- ro: 'Romanian',
- ru: 'Russian',
- sh: 'Serbo-Croatian',
- sk: 'Slovak',
- sl: 'Slovenian',
- sq: 'Albanian',
- sr: 'Serbian',
- sv: 'Swedish',
- ta: 'Tamil',
- tg: 'Tajik',
- tr: 'Turkish',
- uk: 'Ukranian',
- vi: 'Vietnamese'
-};
diff --git a/electron/main.ts b/electron/main.ts
index f80ee42f..47bc3237 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -38,6 +38,12 @@
* @see {@link https://github.com/f-list/exported|GitHub repo}
*/
+// ! Keep first: other modules read userData at import time
+import {
+ isPortable,
+ resolveLogDirectory,
+ toStoredLogDirectory
+} from './portable';
import * as electron from 'electron';
import log from 'electron-log'; //tslint:disable-line:match-default-export-name
@@ -55,14 +61,16 @@ import { IpcMainEvent, session } from 'electron';
import * as browserWindows from './browser_windows';
import * as remoteMain from '@electron/remote/main';
import { Event } from 'electron/main';
-import { autoUpdater } from 'electron-updater';
+import { autoUpdater, UpdateCheckResult } from 'electron-updater';
import Axios from 'axios';
import { ProfileViewerGalleryType } from '../site/utils';
+import { getSafeLanguages } from './language';
import {
cancelSyncJobs,
hasSyncJobs,
registerSyncJobHandlers
} from './services/sync/main-jobs';
+import { registerApiTicketProvider } from './api-ticket';
const configuredSessions = new WeakSet();
@@ -316,7 +324,8 @@ const updateNoticeState: {
let pendingUpdatePromptTag: string | undefined;
if (!fs.existsSync(settingsFile)) {
- shouldImportSettings = true;
+ // ^ Don't pull the host machine's chat data into a fresh portable copy
+ shouldImportSettings = !isPortable;
} else {
try {
Object.assign(
@@ -326,6 +335,7 @@ if (!fs.existsSync(settingsFile)) {
} catch (e) {
log.error(`Error loading settings: ${e}`);
}
+ settings.logDirectory = resolveLogDirectory(settings.logDirectory);
}
// ^ pre-2.4 builds persisted snake_case ids; keep in sync with legacyCodes in chat/localize.ts
@@ -358,7 +368,13 @@ export function updateSpellCheckerLanguages(langs: string[]): void {
function setGeneralSettings(value: GeneralSettings): void {
log.debug('settings.save', value);
- fs.writeFileSync(path.join(settingsDir, 'settings'), JSON.stringify(value));
+ fs.writeFileSync(
+ path.join(settingsDir, 'settings'),
+ JSON.stringify({
+ ...value,
+ logDirectory: toStoredLogDirectory(value.logDirectory)
+ })
+ );
for (const w of electron.webContents.getAllWebContents())
w.send('settings', settings);
@@ -377,7 +393,7 @@ function supportsAutoUpdates(): boolean {
if (process.platform === 'linux') {
return Boolean(process.env.APPIMAGE);
}
- return true;
+ return !isPortable;
}
function isProductionMode(): boolean {
@@ -483,9 +499,9 @@ async function checkForGitRelease(
semVer: string,
releaseUrl: string,
updateMode: 'auto' | 'manual'
-): Promise {
+): Promise {
if (!settings.updateCheck) {
- return;
+ return false;
}
try {
let releases: ReleaseInfo[] = (
@@ -507,14 +523,14 @@ async function checkForGitRelease(
`Update skipped: ${release.tag_name}`
);
setUpdateNotice(false);
- return;
+ return false;
}
if (release.tag_name == semVer) {
log.info(
'updateCheck.state.upToDate',
`Solstice up to date: ${semVer}`
);
- return;
+ return false;
}
log.info(
'updateCheck.state.new',
@@ -523,20 +539,23 @@ async function checkForGitRelease(
setUpdateNotice(true, release.tag_name);
maybeShowUpdatePrompt(release.tag_name, updateMode);
- return;
+ return true;
}
} catch (e) {
log.error(`Error checking for update: ${e}`);
+ return false;
}
+ return false;
}
-async function runAutoUpdateCheck(): Promise {
- if (!settings.updateCheck) return;
+async function runAutoUpdateCheck(): Promise {
+ if (!settings.updateCheck) return null;
try {
autoUpdater.allowPrerelease = settings.beta;
- await autoUpdater.checkForUpdates();
+ return await autoUpdater.checkForUpdates();
} catch (e) {
log.error('autoUpdater.check.failed', e);
+ return null;
}
}
async function requestUpdateDownload(expectedTag?: string): Promise {
@@ -658,6 +677,8 @@ function confirmUpdate(updateVersion: string): boolean {
// Schemes safe to hand to a spawned browser; anything else goes to the OS.
const EXTERNAL_BROWSER_SCHEMES = ['http:', 'https:'];
+// ! Any other scheme reaches whatever app the OS registered for it (ms-msdt:)
+const OS_SCHEMES = ['mailto:', 'irc:'];
function resolveCustomBrowser(): string | null {
const browserPath = settings.browserPath;
@@ -675,9 +696,9 @@ function resolveCustomBrowser(): string | null {
return browserPath;
}
-function isWebUrl(linkUrl: string): boolean {
+function hasScheme(linkUrl: string, schemes: string[]): boolean {
try {
- return EXTERNAL_BROWSER_SCHEMES.includes(new URL(linkUrl).protocol);
+ return schemes.includes(new URL(linkUrl).protocol);
} catch {
return false;
}
@@ -918,13 +939,18 @@ export function openURLExternally(
linkUrl: string,
mode: ExternalOpenMode = 'auto'
): void {
+ if (!hasScheme(linkUrl, [...EXTERNAL_BROWSER_SCHEMES, ...OS_SCHEMES])) {
+ log.warn('openURLExternally.blockedScheme', linkUrl.split(':', 1)[0]);
+ return;
+ }
+
const wantsIncognito =
mode === 'incognito' ||
(mode === 'auto' && settings.horizonAlwaysOpenIncognito);
// A private-window flag is meaningless for mailto: and friends, and macOS has
// no incognito path -- both fall through to the normal open below.
- if (wantsIncognito && isWebUrl(linkUrl)) {
+ if (wantsIncognito && hasScheme(linkUrl, EXTERNAL_BROWSER_SCHEMES)) {
if (process.platform === 'win32') {
openIncognitoWindows(linkUrl);
return;
@@ -938,7 +964,7 @@ export function openURLExternally(
const browserPath = resolveCustomBrowser();
// No custom browser, or not a web link: let the OS handle it.
- if (browserPath === null || !isWebUrl(linkUrl)) {
+ if (browserPath === null || !hasScheme(linkUrl, EXTERNAL_BROWSER_SCHEMES)) {
electron.shell.openExternal(linkUrl);
return;
}
@@ -1399,15 +1425,37 @@ async function onReady(): Promise {
},
{
label: l('action.checkForUpdates'),
- click: (_m: electron.MenuItem, _w: electron.BrowserWindow) => {
+ click: (_m: electron.MenuItem, w: electron.BrowserWindow) => {
if (supportsAutoUpdates()) {
- void runAutoUpdateCheck();
+ runAutoUpdateCheck().then(result => {
+ if (result && result.isUpdateAvailable) {
+ log.info('autoUpdater.manualCheck.success', result);
+ } else {
+ log.info('autoUpdater.manualCheck.noUpdate');
+ electron.dialog.showMessageBoxSync(w, {
+ type: 'info',
+ title: l('title'),
+ message: l('update.noUpdateAvailable')
+ });
+ }
+ });
} else {
void checkForGitRelease(
`v${app.getVersion()}`,
releasesUrl,
'manual'
- );
+ ).then(result => {
+ if (result) {
+ log.info('gitRelease.manualCheck.success', result);
+ } else {
+ log.info('gitRelease.manualCheck.noUpdate');
+ electron.dialog.showMessageBoxSync(w, {
+ type: 'info',
+ title: l('title'),
+ message: l('update.noUpdateAvailable')
+ });
+ }
+ });
}
},
disabled: process.env.NODE_ENV !== 'production',
@@ -1433,11 +1481,7 @@ async function onReady(): Promise {
{
label: l('action.preferences'),
click: (_m, window: electron.BrowserWindow) => {
- browserWindows.createSettingsWindow(
- settings,
- shouldImportSettings ? 'auto' : 'none',
- window
- );
+ window.webContents.send('settings-menu');
},
accelerator: 'CmdOrCtrl+,'
},
@@ -1639,13 +1683,6 @@ async function onReady(): Promise {
);
}
);
- electron.ipcMain.on('open-settings-menu', (_event: IpcMainEvent) => {
- browserWindows.createSettingsWindow(
- settings,
- 'none',
- electron.BrowserWindow.getFocusedWindow()!
- );
- });
electron.ipcMain.on(
'open-exporter-window',
@@ -1660,6 +1697,8 @@ async function onReady(): Promise {
}
);
+ registerApiTicketProvider();
+
electron.ipcMain.on(
'save-login',
(_event: IpcMainEvent, account: string, host: string, proxy: string) => {
@@ -1878,6 +1917,10 @@ async function onReady(): Promise {
let badgeChange =
settings.horizonShowNotificationBadge !==
_options.horizonShowNotificationBadge;
+ let spellcheckChange = !_.isEqual(
+ getSafeLanguages(settings.spellcheckLang),
+ getSafeLanguages(_options.spellcheckLang)
+ );
Object.assign(settings, _options);
//Now we save it to a file
setGeneralSettings(_options);
@@ -1899,10 +1942,23 @@ async function onReady(): Promise {
settings.horizonShowNotificationBadge
);
}
+
+ if (spellcheckChange) {
+ updateSpellCheckerLanguages(
+ getSafeLanguages(settings.spellcheckLang)
+ );
+ }
}
}
);
+ electron.ipcMain.on('request-reload-settings', event => {
+ for (const w of electron.webContents.getAllWebContents()) {
+ if (w.id === event.sender.id) continue;
+ w.send('reload-settings');
+ }
+ });
+
electron.ipcMain.on(
'open-url-externally',
(_e, _url: string, mode: ExternalOpenMode = 'auto') => {
diff --git a/electron/package.json b/electron/package.json
index ce72d7d6..afd76dd6 100644
--- a/electron/package.json
+++ b/electron/package.json
@@ -114,7 +114,7 @@
},
"devDependencies": {
"commander": "^14.0.0",
- "electron": "42.4.1",
+ "electron": "43.8.4",
"electron-builder": "27.0.0-alpha.8"
},
"dependencies": {
diff --git a/electron/platform.ts b/electron/platform.ts
new file mode 100644
index 00000000..65bf0989
--- /dev/null
+++ b/electron/platform.ts
@@ -0,0 +1,56 @@
+import * as electron from 'electron';
+import * as remote from '@electron/remote';
+import { GeneralSettings } from '../chat/general-settings';
+import {
+ DirectoryPickerOptions,
+ FilePickerOptions,
+ PlatformServices,
+ SpellcheckLanguage
+} from '../chat/platform';
+import { formatLanguage } from '../chat/languages';
+
+export default class ElectronPlatformService implements PlatformServices {
+ async saveGeneralSettings(settings: GeneralSettings): Promise {
+ electron.ipcRenderer.send('general-settings-update', settings);
+ }
+
+ async updateLogDirectory(directory: string): Promise {
+ electron.ipcRenderer.send('log-path-update', directory);
+ }
+
+ async reloadSettings(): Promise {
+ electron.ipcRenderer.send('request-reload-settings');
+ }
+
+ async selectDirectory(
+ options: DirectoryPickerOptions = {}
+ ): Promise {
+ const result = await remote.dialog.showOpenDialog({
+ defaultPath: options.defaultPath,
+ properties: ['openDirectory']
+ });
+
+ return result.canceled ? undefined : result.filePaths[0];
+ }
+
+ async selectFile(
+ _options: FilePickerOptions = {}
+ ): Promise {
+ const result = await electron.ipcRenderer.invoke('browser-option-browse');
+ return typeof result === 'string' && result.length > 0 ? result : undefined;
+ }
+
+ async openDirectory(directory: string): Promise {
+ electron.ipcRenderer.send('open-dir', directory);
+ }
+
+ async getSpellcheckLanguages(): Promise> {
+ const languages =
+ remote.getCurrentWindow().webContents.session
+ .availableSpellCheckerLanguages;
+
+ return languages
+ .map(code => ({ code, name: formatLanguage(code) }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ }
+}
diff --git a/electron/portable.ts b/electron/portable.ts
new file mode 100644
index 00000000..18144536
--- /dev/null
+++ b/electron/portable.ts
@@ -0,0 +1,47 @@
+import { app } from 'electron';
+import * as fs from 'fs';
+import * as path from 'path';
+
+const portableDataDirName = 'horizon-data';
+
+function getAppDir(): string {
+ // ^ The Windows portable exe and AppImages run from a temporary extraction
+ if (process.env.PORTABLE_EXECUTABLE_DIR)
+ return process.env.PORTABLE_EXECUTABLE_DIR;
+ return path.dirname(process.env.APPIMAGE || app.getPath('exe'));
+}
+
+const installedLogDirectory = path.join(app.getPath('userData'), 'data');
+const portableDataDir = path.join(getAppDir(), portableDataDirName);
+
+// ! Not on macOS: electron-log hardcodes ~/Library/Logs, and Gatekeeper
+// translocation hides the folder next to apps that haven't been moved
+export const isPortable =
+ process.platform !== 'darwin' && fs.existsSync(portableDataDir);
+
+if (isPortable) {
+ app.setPath('userData', portableDataDir);
+ app.setAppLogsPath(path.join(portableDataDir, 'logs'));
+}
+
+function isPathInside(child: string, parent: string): boolean {
+ const relative = path.relative(parent, child);
+ return (
+ relative !== '..' &&
+ !relative.startsWith(`..${path.sep}`) &&
+ !path.isAbsolute(relative)
+ );
+}
+
+export function resolveLogDirectory(stored: string): string {
+ // ^ Settings copied from an installed copy still point at its data folder
+ if (isPortable && path.resolve(stored) === installedLogDirectory)
+ return path.join(portableDataDir, 'data');
+ return path.resolve(app.getPath('userData'), stored);
+}
+
+// ^ Relative, so the folder survives moving between machines and drive letters
+export function toStoredLogDirectory(dir: string): string {
+ if (!isPortable || !isPathInside(dir, portableDataDir)) return dir;
+ return path.relative(portableDataDir, dir);
+}
diff --git a/electron/release-scripts/linux-portable.sh b/electron/release-scripts/linux-portable.sh
new file mode 100755
index 00000000..77649516
--- /dev/null
+++ b/electron/release-scripts/linux-portable.sh
@@ -0,0 +1,30 @@
+#!/bin/bash -e
+
+# & Usage: ./linux-portable.sh DIST_PATH RELEASE_PATH
+# ** Bundles each AppImage with a horizon-data folder as a portable tar.gz.
+# ^ Not the unpacked app: the AppImage launcher handles the Chromium sandbox
+# on distros that restrict user namespaces.
+
+DIST_PATH="$1"
+RELEASE_PATH="$2"
+
+if [ -z "$DIST_PATH" ] || [ -z "$RELEASE_PATH" ]; then
+ echo "Usage: $0 DIST_PATH RELEASE_PATH"
+ exit 1
+fi
+
+ELECTRON_PATH="$(cd "$(dirname "$0")/.." && pwd)"
+STAGE_PATH="$(mktemp -d)"
+trap 'rm -rf "$STAGE_PATH"' EXIT
+
+mkdir -p "$RELEASE_PATH"
+shopt -s nullglob
+
+for APPIMAGE in "$DIST_PATH"/*.AppImage; do
+ BUNDLE_PATH="$STAGE_PATH/Horizon"
+ rm -rf "$BUNDLE_PATH"
+ mkdir -p "$BUNDLE_PATH/horizon-data"
+ cp "$APPIMAGE" "$BUNDLE_PATH/"
+ cp "$ELECTRON_PATH/build/portable-readme.txt" "$BUNDLE_PATH/horizon-data/README.txt"
+ tar -czf "$RELEASE_PATH/$(basename "$APPIMAGE" .AppImage)-portable.tar.gz" -C "$STAGE_PATH" Horizon
+done
diff --git a/electron/release-scripts/linux.sh b/electron/release-scripts/linux.sh
index beab351c..78a5d5ee 100755
--- a/electron/release-scripts/linux.sh
+++ b/electron/release-scripts/linux.sh
@@ -48,3 +48,4 @@ cp "$DIST_PATH"/*.rpm "$RELEASE_PATH"/ 2>/dev/null || true
cp "$DIST_PATH"/*.AppImage "$RELEASE_PATH"/ 2>/dev/null || true
cp "$DIST_PATH"/latest*.yml "$RELEASE_PATH"/ 2>/dev/null || true
cp "$DIST_PATH"/*.blockmap "$RELEASE_PATH"/ 2>/dev/null || true
+bash "$REPO_ROOT/electron/release-scripts/linux-portable.sh" "$DIST_PATH" "$RELEASE_PATH"
diff --git a/electron/release-scripts/macos-dev.sh b/electron/release-scripts/macos-dev.sh
index bcfbf537..b71cfe0a 100644
--- a/electron/release-scripts/macos-dev.sh
+++ b/electron/release-scripts/macos-dev.sh
@@ -39,15 +39,13 @@ cd electron
rm -rf app dist
# Create dist directory for logging before build
mkdir -p "$DIST_PATH"
-if [ -n "$ARCH" ]; then
- pnpm run webpack:dev
- node build/build.mjs --os macos --format zip --arch "$ARCH"
-else
- pnpm build:dev:mac
-fi
+pnpm run webpack:dev
+node build/build.mjs --os macos --format dmg --arch "${ARCH:-universal}"
# & Prepare release directory
mkdir -p "$RELEASE_PATH"
-# & Copy artifacts (both dmg and zip files)
-cp "$DIST_PATH"/*.zip "$RELEASE_PATH"/ 2>/dev/null || true
+# & Copy artifacts
+# ^ A dmg rather than a zip: the CI artifact is already a zip, and the .app
+# can't go in bare because artifacts drop its symlinks and exec bits.
+cp "$DIST_PATH"/*.dmg "$RELEASE_PATH"/ 2>/dev/null || true
diff --git a/electron/release-scripts/windows-dev.ps1 b/electron/release-scripts/windows-dev.ps1
index f42ed8a9..a123d39e 100644
--- a/electron/release-scripts/windows-dev.ps1
+++ b/electron/release-scripts/windows-dev.ps1
@@ -3,7 +3,7 @@
<#
.SYNOPSIS
Windows dev/PR build script for F-Chat Horizon.
-Builds portable EXE only (no installer, no signtool/elevate).
+Builds the portable app folder only (no installer, no signtool/elevate).
.PARAMETER ReleaseVersion
The version string for the release.
@@ -46,15 +46,11 @@ pnpm install --frozen-lockfile
# Build the project
Set-Location electron
Remove-Item -Recurse -Force app, dist -ErrorAction SilentlyContinue
+pnpm run webpack:dev
if ($Arch) {
- pnpm run webpack:dev
- node build/build.mjs --os windows --format portable --arch $Arch
+ node build/build.mjs --os windows --format dir --arch $Arch
} else {
- pnpm build:dev:win
+ node build/build.mjs --os windows --format dir
}
-# Prepare release directory
-New-Item -ItemType Directory -Path $ReleasePath -Force | Out-Null
-
-# Copy portable exe artifact
-Copy-Item "$DistPath\*.exe" -Destination "$ReleasePath\" -ErrorAction SilentlyContinue
+& "$PSScriptRoot\windows-portable.ps1" -DistPath $DistPath -ReleasePath $ReleasePath -NoArchive
diff --git a/electron/release-scripts/windows-portable.ps1 b/electron/release-scripts/windows-portable.ps1
new file mode 100644
index 00000000..4ea4200a
--- /dev/null
+++ b/electron/release-scripts/windows-portable.ps1
@@ -0,0 +1,57 @@
+#!/usr/bin/env pwsh
+
+<#
+.SYNOPSIS
+Packages each unpacked Windows build in electron\dist as a portable zip.
+
+.DESCRIPTION
+Adds the horizon-data folder here rather than through electron-builder's
+extraFiles, which would also ship it in the NSIS installer.
+
+.PARAMETER DistPath
+The electron-builder output directory containing win*-unpacked folders.
+
+.PARAMETER ReleasePath
+The directory where the zips are written.
+
+.PARAMETER NoArchive
+Copy the portable folders instead of zipping them, for CI artifacts that GitHub
+already zips.
+
+.EXAMPLE
+.\windows-portable.ps1 -DistPath "C:\Horizon\electron\dist" -ReleasePath "C:\release"
+#>
+
+Param(
+ [Parameter(Mandatory = $true)]
+ [string]$DistPath,
+
+ [Parameter(Mandatory = $true)]
+ [string]$ReleasePath,
+
+ [switch]$NoArchive
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+$ElectronPath = Split-Path $PSScriptRoot -Parent
+$Version = (Get-Content "$ElectronPath\package.json" -Raw | ConvertFrom-Json).version
+
+New-Item -ItemType Directory -Path $ReleasePath -Force | Out-Null
+
+foreach ($Unpacked in Get-ChildItem $DistPath -Directory -Filter "win*-unpacked") {
+ # * electron-builder leaves the arch out of the folder name for x64
+ $Arch = if ($Unpacked.Name -match '^win-(.+)-unpacked$') { $Matches[1] } else { "x64" }
+
+ $DataPath = Join-Path $Unpacked.FullName "horizon-data"
+ New-Item -ItemType Directory -Path $DataPath -Force | Out-Null
+ Copy-Item "$ElectronPath\build\portable-readme.txt" -Destination "$DataPath\README.txt"
+
+ $OutPath = Join-Path $ReleasePath "F-Chat.Horizon-$Version-win-$Arch-portable"
+ if ($NoArchive) {
+ Copy-Item $Unpacked.FullName -Destination $OutPath -Recurse
+ } else {
+ Compress-Archive -Path "$($Unpacked.FullName)\*" -DestinationPath "$OutPath.zip" -Force
+ }
+}
diff --git a/electron/release-scripts/windows.ps1 b/electron/release-scripts/windows.ps1
index 27bd8576..aa50a31b 100644
--- a/electron/release-scripts/windows.ps1
+++ b/electron/release-scripts/windows.ps1
@@ -62,5 +62,6 @@ New-Item -ItemType Directory -Path $ReleasePath -Force | Out-Null
Copy-Item "$DistPath\*.exe" -Destination "$ReleasePath\" -ErrorAction SilentlyContinue
Copy-Item "$DistPath\latest*.yml" -Destination "$ReleasePath\" -ErrorAction SilentlyContinue
Copy-Item "$DistPath\*.blockmap" -Destination "$ReleasePath\" -ErrorAction SilentlyContinue
+& "$PSScriptRoot\windows-portable.ps1" -DistPath $DistPath -ReleasePath $ReleasePath
# TODO: Allow specifying the branch to build from
diff --git a/electron/settings.html b/electron/settings.html
deleted file mode 100644
index eb2838a5..00000000
--- a/electron/settings.html
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
- Settings
-
-
-
-
-
-
-
-
diff --git a/electron/settings.ts b/electron/settings.ts
deleted file mode 100644
index 750e3718..00000000
--- a/electron/settings.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-//For potential git history reasons, this file used to be named "browser_options.ts" prior to f257b4c6a9d6fc06c1a6f7354e38c2dbd7bc69f6
-import * as qs from 'querystring';
-import log from 'electron-log'; //tslint:disable-line:match-default-export-name
-
-import { GeneralSettings } from './common';
-import BrowserOption from './Settings.vue';
-
-log.info('init.settings');
-
-const params = <{ [key: string]: string | undefined }>(
- qs.parse(window.location.search.substr(1))
-);
-const settings = JSON.parse(params['settings']!);
-
-const logLevel = process.env.NODE_ENV === 'production' ? 'info' : 'silly';
-
-log.transports.file.level = settings.risingSystemLogLevel || logLevel;
-log.transports.console.level = settings.risingSystemLogLevel || logLevel;
-log.transports.file.maxSize = 5 * 1024 * 1024;
-
-log.info('init.settings.vue');
-
-new BrowserOption({
- el: '#browserOption',
- data: { settings }
-});
-
-log.debug('init.settings.vue.done');
diff --git a/electron/webpack.config.js b/electron/webpack.config.js
index ad7ff3d3..8b402f6a 100644
--- a/electron/webpack.config.js
+++ b/electron/webpack.config.js
@@ -102,11 +102,6 @@ const mainConfig = {
path.join(__dirname, 'window.html'),
path.join(__dirname, 'build', 'tray@2x.png')
],
- settings: [
- path.join(__dirname, 'settings.ts'),
- path.join(__dirname, 'settings.html'),
- path.join(__dirname, 'build', 'tray@2x.png')
- ],
changelog: [
path.join(__dirname, 'changelog.ts'),
path.join(__dirname, 'changelog.html'),
diff --git a/fchat/connection.ts b/fchat/connection.ts
index ad12bfda..38811a30 100644
--- a/fchat/connection.ts
+++ b/fchat/connection.ts
@@ -10,10 +10,8 @@ const fatalErrors = [2, 3, 4, 9, 30, 31, 33, 39, 40, 62, -4];
const dieErrors = [9, 30, 31, 39, 40];
let lastFetch = Date.now();
-let lastApiTicketFetch = Date.now();
const queryApiThroat = throat(2);
-const queryTicketThroat = throat(1);
async function queryApi(
this: void,
@@ -49,6 +47,7 @@ export default class Connection implements Interfaces.Connection {
private ticketProvider?: Interfaces.TicketProvider;
private reconnectDelay = 0;
private isReconnect = false;
+ private invalidTicket?: string;
private pinTimeout?: ReturnType;
constructor(
@@ -59,13 +58,10 @@ export default class Connection implements Interfaces.Connection {
setCredentials(
account: string,
- ticketProvider: Interfaces.TicketProvider | string
+ ticketProvider: Interfaces.TicketProvider
): void {
this.account = account;
- this.ticketProvider =
- typeof ticketProvider === 'string'
- ? async () => this.getTicket(ticketProvider)
- : ticketProvider;
+ this.ticketProvider = ticketProvider;
}
async connect(character: string): Promise {
@@ -74,7 +70,8 @@ export default class Connection implements Interfaces.Connection {
if (this.character !== character) this.isReconnect = false;
this.character = character;
try {
- this.ticket = await this.ticketProvider();
+ this.ticket = await this.ticketProvider(this.invalidTicket);
+ this.invalidTicket = undefined;
} catch (e) {
if (this.reconnectTimer !== undefined)
if ((e).request !== undefined) this.reconnect();
@@ -84,7 +81,13 @@ export default class Connection implements Interfaces.Connection {
try {
await this.invokeHandlers('connecting', this.isReconnect);
} catch (e) {
- await this.invokeHandlers('closed', false);
+ // keep retrying to reconnectafter a network failure
+ if (
+ this.reconnectTimer !== undefined &&
+ (e).request !== undefined
+ )
+ this.reconnect();
+ else await this.invokeHandlers('closed', false);
return this.invokeErrorHandlers(e);
}
if (this.cleanClose) {
@@ -191,7 +194,7 @@ export default class Connection implements Interfaces.Connection {
throw new Error('No credentials set!');
}
- this.ticket = await queryTicketThroat(async () => this.ticketProvider!());
+ this.ticket = await this.ticketProvider(oldTicket);
log.debug('api.ticket.renew.resolve.refresh', {
character: core.characters.ownCharacter?.name
@@ -210,8 +213,7 @@ export default class Connection implements Interfaces.Connection {
endpoint,
data,
character: core.characters.ownCharacter?.name,
- deltaToLastApiCall: Date.now() - lastFetch,
- deltaToLastApiTicket: Date.now() - lastApiTicketFetch
+ deltaToLastApiCall: Date.now() - lastFetch
});
if (data === undefined) data = {};
@@ -229,8 +231,7 @@ export default class Connection implements Interfaces.Connection {
log.debug('api.ticket.loss', {
error: res.error,
character: core.characters.ownCharacter?.name,
- deltaToLastApiCall: Date.now() - lastFetch,
- deltaToLastApiTicket: Date.now() - lastApiTicketFetch
+ deltaToLastApiCall: Date.now() - lastFetch
});
data.ticket = await this.refreshTicket(data.ticket);
@@ -243,8 +244,7 @@ export default class Connection implements Interfaces.Connection {
endpoint,
data,
character: core.characters.ownCharacter?.name,
- deltaToLastApiCall: Date.now() - lastFetch,
- deltaToLastApiTicket: Date.now() - lastApiTicketFetch
+ deltaToLastApiCall: Date.now() - lastFetch
});
const error = new Error(res.error);
@@ -256,8 +256,7 @@ export default class Connection implements Interfaces.Connection {
endpoint,
data,
character: core.characters.ownCharacter?.name,
- deltaToLastApiCall: Date.now() - lastFetch,
- deltaToLastApiTicket: Date.now() - lastApiTicketFetch
+ deltaToLastApiCall: Date.now() - lastFetch
});
return res;
@@ -351,6 +350,8 @@ export default class Connection implements Interfaces.Connection {
}
break;
case 'ERR':
+ // don't reuse the failed ticket, since it's invalid
+ if (data.number === 4) this.invalidTicket = this.ticket;
if (fatalErrors.indexOf(data.number) !== -1) {
this.invokeErrorHandlers(new Error(data.message), true);
if (dieErrors.indexOf(data.number) !== -1) {
@@ -370,53 +371,6 @@ export default class Connection implements Interfaces.Connection {
//tslint:enable
- private async getTicket(password: string): Promise {
- console.log('Acquiring new API ticket');
- const oldLastApiTicketFetch = lastApiTicketFetch;
-
- log.debug('api.getTicket.start', {
- character: core.characters.ownCharacter?.name,
- deltaToLastApiCall: Date.now() - lastFetch,
- deltaToLastApiTicket: Date.now() - oldLastApiTicketFetch
- });
-
- lastApiTicketFetch = Date.now();
-
- const data = <{ ticket?: string; error: string }>(
- await Axios.post(
- 'https://www.f-list.net/json/getApiTicket.php',
- qs.stringify({
- account: this.account,
- password,
- no_friends: true,
- no_bookmarks: true,
- no_characters: true
- })
- )
- ).data;
-
- if (data.ticket !== undefined) {
- log.debug('api.getTicket.success', {
- character: core.characters.ownCharacter?.name,
- deltaToLastApiCall: Date.now() - lastFetch,
- deltaToLastApiTicket: Date.now() - oldLastApiTicketFetch
- });
-
- return data.ticket;
- }
-
- console.error('API Ticket Error', data.error);
-
- log.error('error.api.getTicket', {
- character: core.characters.ownCharacter.name,
- error: data.error,
- deltaToLastApiCall: Date.now() - lastFetch,
- deltaToLastApiTicket: Date.now() - oldLastApiTicketFetch
- });
-
- throw new Error(data.error);
- }
-
private async invokeHandlers(
type: Interfaces.EventType,
isReconnect: boolean
diff --git a/fchat/interfaces.ts b/fchat/interfaces.ts
index 28d67288..fbc177cd 100644
--- a/fchat/interfaces.ts
+++ b/fchat/interfaces.ts
@@ -243,7 +243,7 @@ export namespace Connection {
data: ServerCommands[T],
date: Date
) => Promise | void;
- export type TicketProvider = () => Promise;
+ export type TicketProvider = (invalidTicket?: string) => Promise;
export type EventType = 'connecting' | 'connected' | 'closed';
export type EventHandler = (isReconnect: boolean) => Promise | void;
@@ -263,10 +263,7 @@ export namespace Connection {
readonly character: string;
readonly vars: Readonly;
readonly isOpen: boolean;
- setCredentials(
- account: string,
- ticketProvider: TicketProvider | string
- ): void;
+ setCredentials(account: string, ticketProvider: TicketProvider): void;
connect(character: string): void;
close(keepState?: boolean): void;
onMessage(
diff --git a/flake.nix b/flake.nix
index 75d04af4..09a2725e 100644
--- a/flake.nix
+++ b/flake.nix
@@ -23,7 +23,7 @@
pnpmDeps = pkgs.fetchPnpmDeps {
inherit pname version src;
- hash = "sha256-RCBYQW/+stREqGY/ENN0NbL0r/sfwq32h1jhBG+CicI=";
+ hash = "sha256-hx+Qwa8j8kb02xSyJ77Ddxp1yk2BwDcxoEJhq6Y8ofg=";
pnpm = pkgs.pnpm_11;
fetcherVersion = 4;
};
diff --git a/learn/cache-manager.ts b/learn/cache-manager.ts
index 0f9eec86..4f94a24b 100644
--- a/learn/cache-manager.ts
+++ b/learn/cache-manager.ts
@@ -143,8 +143,17 @@ export class CacheManager {
const key = ProfileCache.nameKey(name);
- if (!!_.find(this.queue, (q: ProfileCacheQueueEntry) => q.key === key))
+ const existing = _.find(
+ this.queue,
+ (q: ProfileCacheQueueEntry) => q.key === key
+ );
+
+ // retag to the newest channel so a channel switch doesn't drop someone the
+ // new channel just asked for. untagged entries stay untagged
+ if (existing) {
+ if (existing.channelId) existing.channelId = channelId;
return;
+ }
const entry: ProfileCacheQueueEntry = {
name,
@@ -245,38 +254,20 @@ export class CacheManager {
*
* But under what scenarios do we actually need to process without fetching?
* @param character Character name to fetch, or a character object to finish
- * @param fromDiskOnly If true, only attempt to load from disk cache; do not fetch from server
- * @param deleteAfterFetch If true, delete the profile from cache immediately after fetching
* Comment imported from Frolic; may be inaccurate if significant changes occured.
+
+ * trimmed significantly with the userlist viertualization changes. fromDiskOnly
+ * caused every profile to reregister no matter what, introducing extra work.
+ * applyOverridesFromStore does the same job without reregistering.
*/
async addProfile(
character: string | ComplexCharacter,
- fromDiskOnly: boolean = false,
- deleteAfterFetch: boolean = false
+ channelId?: string
): Promise {
if (typeof character === 'string') {
// console.log('Learn discover', character);
- if (fromDiskOnly) {
- const diskChar = await this.profileCache.get(character);
- if (deleteAfterFetch && diskChar) {
- this.profileCache.delete(character);
- }
- return;
- }
- await this.queueForFetching(character);
- if (deleteAfterFetch) {
- // Wait until fetched, then delete
- const checkAndDelete = async () => {
- const p = await this.profileCache.get(character);
- if (p) {
- this.profileCache.delete(character);
- } else {
- setTimeout(checkAndDelete, 1000);
- }
- };
- await checkAndDelete();
- }
+ await this.queueForFetching(character, false, channelId);
return;
}
diff --git a/learn/filter/smart-filter.ts b/learn/filter/smart-filter.ts
index 19faadef..789fc42d 100644
--- a/learn/filter/smart-filter.ts
+++ b/learn/filter/smart-filter.ts
@@ -3,6 +3,7 @@ import { Matcher } from '../matcher';
import {
BodyType,
Build,
+ fchatGenderMap,
FurryPreference,
Gender,
Kink,
@@ -45,6 +46,10 @@ function getBaseLog(base: number, x: number): number {
export class SmartFilter {
constructor(private opts: SmartFilterOpts) {}
+ get genders(): Gender[] | undefined {
+ return this.opts.genders;
+ }
+
test(c: Character): SmartFilterTestResult {
const builds = this.testBuilds(c);
const bodyTypes = this.testBodyTypes(c);
@@ -417,6 +422,49 @@ export const smartFilters: SmartFilterCollection = {
})
};
+// gender is the one thing the chat protocol already tells us about everyone, so
+// the gender filters can be done without a profile read. i have no idea why
+// we weren't doing this already
+const genderFilterKeys = new Map();
+
+for (const [key, filter] of Object.entries(smartFilters)) {
+ for (const gender of filter.genders || []) {
+ const keys = genderFilterKeys.get(gender) || [];
+ keys.push(key as keyof SmartFilterSelection);
+ genderFilterKeys.set(gender, keys);
+ }
+}
+
+export interface ChatMember {
+ readonly name: string;
+ readonly gender?: string;
+ readonly isChatOp?: boolean;
+ readonly isBookmarked?: boolean;
+ readonly isFriend?: boolean;
+}
+
+/**
+ * Whether the gender smart filters alone hide someone, using only their chat
+ * gender. isFiltered is an OR, so a match here is decisive and needs no profile read.
+ * a false only means the gender filters don't hide them, not that nothing else will.
+ *
+ * Mirrors the exemptions in {@link testSmartFilters | `testSmartFilters`}.
+ */
+export function isFilteredByChatGender(
+ member: ChatMember,
+ opts: SmartFilterSettings
+): boolean {
+ if (!member.gender) return false;
+ if (member.name === core.characters.ownCharacter.name) return false;
+ if (member.isChatOp || member.isBookmarked || member.isFriend) return false;
+ if (opts.exceptionNames.includes(member.name)) return false;
+
+ const gender = fchatGenderMap[member.gender];
+ const keys = gender === undefined ? undefined : genderFilterKeys.get(gender);
+
+ return !!keys && keys.some(key => opts.smartFilters[key]);
+}
+
export function testSmartFilters(
c: Character,
opts: SmartFilterSettings
@@ -492,5 +540,5 @@ export function matchesSmartFilters(
return true;
}
- return !_.every(match.filters, filterResult => !filterResult.isFiltered);
+ return !_.every(match.filters, filterResult => !filterResult?.isFiltered);
}
diff --git a/learn/filter/types.ts b/learn/filter/types.ts
index 3bf9b4c4..7136d28c 100644
--- a/learn/filter/types.ts
+++ b/learn/filter/types.ts
@@ -50,3 +50,11 @@ export interface SmartFilterSettings {
exceptionNames: string[];
}
+
+export function hasActiveSmartFilters(opts: SmartFilterSettings): boolean {
+ return (
+ opts.minAge !== null ||
+ opts.maxAge !== null ||
+ Object.values(opts.smartFilters).some(enabled => enabled)
+ );
+}
diff --git a/learn/matcher-types.ts b/learn/matcher-types.ts
index 3e1b390c..10c472ad 100644
--- a/learn/matcher-types.ts
+++ b/learn/matcher-types.ts
@@ -125,6 +125,8 @@ export enum Kink {
UnderageCharacters = 207,
RoleReversal = 408,
+ StorytellingNarrator = 577,
+ StorytellingPlayer = 578,
AnthroCharacters = 587,
Humans = 609,
@@ -172,7 +174,7 @@ export enum Kink {
ExtremeMusk = 335,
Farting = 549,
Filth = 707,
- Messy = 89,
+ Messy = 87,
Slob = 570,
Smegma = 708,
SwallowingVomit = 560,
@@ -243,7 +245,40 @@ export enum Kink {
GrowthMacro = 384,
ShrinkingMicro = 387,
- Taurs = 68
+ Taurs = 68,
+
+ AnalSexGiving = 137,
+ AnalSexReceiving = 157,
+ RimmingGiving = 163,
+ RimmingReceiving = 16,
+ VaginalSexGiving = 229,
+ VaginalSexReceiving = 340,
+ CunnilingusGiving = 513,
+ CunnilingusReceiving = 515,
+ OralSexGiving = 141,
+ OralSexReceiving = 158,
+ FellatioPerforming = 512,
+ FellatioReceiving = 514,
+
+ Amputees = 585,
+ BodyHair = 588,
+ CanonCharacters = 589,
+ Chubby = 458,
+ Disabilities = 605,
+ FacialHairBeards = 590,
+ Femboys = 531,
+ Femininity = 592,
+ ShorterCharacters = 600,
+ SkinnyCharacters = 601,
+ SlimeGooCharacters = 584,
+ SuperheroesVillains = 616,
+ TallerCharacters = 603,
+ Tomboys = 532,
+ Toons = 720,
+ Twinks = 354,
+ VeryFatCharacters = 183,
+ VeryLitheCharacters = 85,
+ VeryMuscularCharacters = 84
}
export enum FurryPreference {
@@ -785,7 +820,6 @@ export const speciesMapping: SpeciesMap = {
'colt',
'filly',
'neigh',
- 'dullahan',
'tikbalang',
'gypsy[ -]?vanner',
'ardenne[r]?',
@@ -1705,6 +1739,7 @@ export const speciesMapping: SpeciesMap = {
'fairy',
'fae',
'imp',
+ 'dullahan',
'elemental',
'fey',
'pixie',
@@ -2041,48 +2076,57 @@ export const kinkMatchScoreMap = {
};
export const kinkComparisonExclusions = {
- 585: true, // amputees
- 587: true, // anthro characters
- 588: true, // body hair
- 589: true, // canon characters
- 458: true, // chubby
- 605: true, // disabilities
- 590: true, // facial hair / beards
- 531: true, // femboys
- 592: true, // femininity
- 109: true, // older characters
- 600: true, // shorter characters
- 601: true, // skinny characters
- 584: true, // slime / goo characters
- 616: true, // superheroes / villains
- 603: true, // taller characters
- 532: true, // tomboys
- 720: true, // toons
- 354: true, // twinks
- 183: true, // very fat characters
- 85: true, // very lithe characters
- 84: true, // very muscular characters
- 197: true // younger characters
+ [Kink.Amputees]: true,
+ [Kink.AnthroCharacters]: true,
+ [Kink.BodyHair]: true,
+ [Kink.CanonCharacters]: true,
+ [Kink.Chubby]: true,
+ [Kink.Disabilities]: true,
+ [Kink.FacialHairBeards]: true,
+ [Kink.Femboys]: true,
+ [Kink.Femininity]: true,
+ [Kink.OlderCharacters]: true,
+ [Kink.ShorterCharacters]: true,
+ [Kink.SkinnyCharacters]: true,
+ [Kink.SlimeGooCharacters]: true,
+ [Kink.SuperheroesVillains]: true,
+ [Kink.TallerCharacters]: true,
+ [Kink.Tomboys]: true,
+ [Kink.Toons]: true,
+ [Kink.Twinks]: true,
+ [Kink.VeryFatCharacters]: true,
+ [Kink.VeryLitheCharacters]: true,
+ [Kink.VeryMuscularCharacters]: true,
+ [Kink.YoungerCharacters]: true
};
+export enum KinkGroupId {
+ GenderPreferences = 29,
+ SpeciesPreferences = 30
+}
+
export const kinkComparisonExclusionGroups = {
- 29: true, // gender preferences
- 30: true // species preferences
+ [KinkGroupId.GenderPreferences]: true,
+ [KinkGroupId.SpeciesPreferences]: true
};
export const kinkComparisonSwaps: Record = {
- 137: 157, // anal sex giving -> receiving
- 157: 137, // anal sex receiving -> giving
- 163: 16, // rimming giving -> receiving
- 16: 163, // rimming receiving -> giving
- 229: 340, // vaginal sex giving -> receiving
- 340: 229, // vaginal sex receiving -> giving
- 513: 515, // cunnilingus giving -> receiving
- 515: 513, // cunnilingus receiving -> giving
- 141: 158, // oral sex giving -> receiving
- 158: 141, // oral sex receiving -> giving
- 512: 514, // fellatio performing -> fellatio receiving
- 514: 512 // fellatio receiving -> fellatio performing
+ [Kink.AnalSexGiving]: Kink.AnalSexReceiving, // anal sex giving -> receiving
+ [Kink.AnalSexReceiving]: Kink.AnalSexGiving, // anal sex receiving -> giving
+ [Kink.RimmingGiving]: Kink.RimmingReceiving, // rimming giving -> receiving
+ [Kink.RimmingReceiving]: Kink.RimmingGiving, // rimming receiving -> giving
+ [Kink.VaginalSexGiving]: Kink.VaginalSexReceiving, // vaginal sex giving -> receiving
+ [Kink.VaginalSexReceiving]: Kink.VaginalSexGiving, // vaginal sex receiving -> giving
+ [Kink.CunnilingusGiving]: Kink.CunnilingusReceiving, // cunnilingus giving -> receiving
+ [Kink.CunnilingusReceiving]: Kink.CunnilingusGiving, // cunnilingus receiving -> giving
+ [Kink.OralSexGiving]: Kink.OralSexReceiving, // oral sex giving -> receiving
+ [Kink.OralSexReceiving]: Kink.OralSexGiving, // oral sex receiving -> giving
+ [Kink.FellatioPerforming]: Kink.FellatioReceiving, // fellatio performing -> fellatio receiving
+ [Kink.FellatioReceiving]: Kink.FellatioPerforming, // fellatio receiving -> fellatio performing
+ [Kink.VoreBeingPredator]: Kink.VoreBeingPrey, // vore predator -> prey
+ [Kink.VoreBeingPrey]: Kink.VoreBeingPredator, // vore prey -> predator
+ [Kink.StorytellingNarrator]: Kink.StorytellingPlayer, // storytelling narrator -> player
+ [Kink.StorytellingPlayer]: Kink.StorytellingNarrator // storytelling player -> narrator
};
export interface KinkBucketScore {
diff --git a/learn/matcher.ts b/learn/matcher.ts
index 2e94bd56..b41f68ca 100644
--- a/learn/matcher.ts
+++ b/learn/matcher.ts
@@ -202,6 +202,19 @@ export class CharacterAnalysis {
* to get the full picture
*/
export class Matcher {
+ static readonly ageplayAgeStrings = [
+ 'shota',
+ 'loli',
+ 'lolli',
+ 'pup',
+ 'brat',
+ '🚨',
+ '🔞',
+ '👮',
+ '🚼',
+ '👶',
+ '🍼'
+ ];
readonly you: Character;
readonly them: Character;
@@ -1570,13 +1583,10 @@ export class Matcher {
const ageStr = rawAge.string.toLowerCase().replace(/[,.]/g, '').trim();
- if (
- ageStr.indexOf('shota') >= 0 ||
- ageStr.indexOf('loli') >= 0 ||
- ageStr.indexOf('lolli') >= 0 ||
- ageStr.indexOf('pup') >= 0
- ) {
- return 10;
+ for (const ageKeyword of this.ageplayAgeStrings) {
+ if (ageStr.indexOf(ageKeyword) >= 0) {
+ return 10;
+ }
}
let age: number | null = null;
@@ -1628,13 +1638,10 @@ export class Matcher {
return { min: Math.min(v1, v2), max: Math.max(v1, v2) };
}
- if (
- ageStr.indexOf('shota') >= 0 ||
- ageStr.indexOf('loli') >= 0 ||
- ageStr.indexOf('lolli') >= 0 ||
- ageStr.indexOf('pup') >= 0
- ) {
- return { min: 10, max: 10 };
+ for (const ageKeyword of this.ageplayAgeStrings) {
+ if (ageStr.indexOf(ageKeyword) >= 0) {
+ return { min: 10, max: 10 };
+ }
}
return null;
diff --git a/learn/profile-cache.ts b/learn/profile-cache.ts
index 76b08808..efe160a7 100644
--- a/learn/profile-cache.ts
+++ b/learn/profile-cache.ts
@@ -266,6 +266,55 @@ export class ProfileCache extends AsyncCache {
return cacheRecord;
}
+ /**
+ * Applies a profile's character overrides (HQ portrait, custom name color).
+ *
+ * Skips register on purpose. Registering runs the matcher, which costs performance
+ * per profile, and the member list calls this once per member. Nothing here needs
+ * a match score, so don't add one.
+ *
+ * Does nothing if the profile isn't on disk
+ * @param name Character to apply overrides for
+ */
+ async applyOverridesFromStore(name: string): Promise {
+ const profile =
+ this.getSync(name)?.character ??
+ (await this.store?.getProfile(name))?.profileData;
+
+ if (profile) {
+ this.updateOverrides(profile);
+ }
+ }
+
+ /**
+ * Tests a stored profile against the smart filters without scoring it.
+ *
+ * Same reasoning as applyOverridesFromStore. matchesSmartFilters is cheap, but
+ * register is not, so don't go through it just to get a verdict. Reads the cache
+ * directly instead of via getSync so a bulk scan doesn't reorder the LRU
+ *
+ * @param name Character to test
+ * @returns whether they're filtered, or null if there's no profile to judge
+ */
+ async isFilteredFromStore(name: string): Promise {
+ const cached = this.cache[AsyncCache.nameKey(name)];
+
+ if (cached) {
+ return cached.match.isFiltered;
+ }
+
+ const profile = (await this.store?.getProfile(name))?.profileData;
+
+ if (!profile) {
+ return null;
+ }
+
+ return matchesSmartFilters(
+ profile.character,
+ core.state.settings.risingFilter
+ );
+ }
+
delete(name: string): void {
const key = AsyncCache.nameKey(name);
diff --git a/mobile/AppSettingsDialog.vue b/mobile/AppSettingsDialog.vue
deleted file mode 100644
index 714d0a06..00000000
--- a/mobile/AppSettingsDialog.vue
+++ /dev/null
@@ -1,84 +0,0 @@
-
-
-
diff --git a/mobile/Index.vue b/mobile/Index.vue
index c8511d03..d479b661 100644
--- a/mobile/Index.vue
+++ b/mobile/Index.vue
@@ -1,5 +1,5 @@
-