Răsfoiți Sursa

Merge remote-tracking branch 'origin/master' into neo-manifest-icons

Andrey Yasko 10 ore în urmă
părinte
comite
938e60064d

+ 18 - 34
.github/workflows/ci.yml

@@ -155,14 +155,7 @@ jobs:
       RAILWAY_BASE_ENVIRONMENT: ${{ secrets.RAILWAY_ENVIRONMENT }}
       RAILWAY_TEMPLATE_ENVIRONMENT: pr-template
       RAILWAY_PR_ENVIRONMENT: pr-${{ github.event.pull_request.number }}
-      # unacms.app hosts: UNA (service una) and the NEO web client (service neo), both on Railway
-      VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
-      VERCEL_TEAM: yasko
-      PREVIEW_API_HOST: api-pr-${{ github.event.pull_request.number }}.unacms.app
-      PREVIEW_CLIENT_HOST: pr-${{ github.event.pull_request.number }}.unacms.app
       UNA_PREVIEW_API_KEY: ${{ secrets.UNA_PREVIEW_API_KEY }}
-    outputs:
-      domains: ${{ steps.domains.outputs.ok }}
     steps:
     - uses: actions/checkout@v4
       with:
@@ -208,30 +201,27 @@ jobs:
             MYSQLUSER=una \
             MYSQLPASSWORD=una \
             UNA_DEMO_SEED=1 \
-            UNA_CLIENT_URL="https://$PREVIEW_CLIENT_HOST" \
             ${UNA_PREVIEW_API_KEY:+UNA_API_KEY="$UNA_PREVIEW_API_KEY"} \
             && break
           sleep 5
         done
+        # Previews live on their Railway domains (<service>-pr-<n>.up.railway.app), which share
+        # Railway's wildcard certificate. Custom pr-<n> / api-pr-<n> hosts on unacms.app needed two
+        # Let's Encrypt certificates per PR, and a busy week ran into Let's Encrypt's rate limit
+        # (PR #5934 got none). UNA_AUTO_HOSTNAME makes UNA build its URLs from its host.
         railway domain --service una --environment "$RAILWAY_PR_ENVIRONMENT" || true
         railway domain --service neo --environment "$RAILWAY_PR_ENVIRONMENT" || true
-
-        # NEO talks to this PR's UNA through its Railway domain, which has a certificate from
-        # the start (the api-pr host gets one minutes later). UNA_AUTO_HOSTNAME makes UNA build
-        # its URLs from that host.
-        una_host=$(railway domain list --service una --environment "$RAILWAY_PR_ENVIRONMENT" --json | jq -r '.domains[] | select(.type == "service") | .domain' | head -n1)
+        service_host() {
+          railway domain list --service "$1" --environment "$RAILWAY_PR_ENVIRONMENT" --json | jq -r '.domains[] | select(.type == "service") | .domain' | head -n1
+        }
+        una_host=$(service_host una)
+        neo_host=$(service_host neo)
         [ -n "$una_host" ] || { echo "No Railway domain for una" >&2; exit 1; }
-        scripts/railway/neo.sh vars "$RAILWAY_PR_ENVIRONMENT" "https://${una_host#https://}" "https://$PREVIEW_CLIENT_HOST"
-
-    - name: Preview domains on unacms.app
-      id: domains
-      if: env.VERCEL_TOKEN != ''
-      run: |
-        scripts/railway/domains.sh railway-domain "$PREVIEW_API_HOST" "$RAILWAY_PR_ENVIRONMENT" una
-        # Until 2026-10 the client host was a Vercel alias of the neo-ci build; drop it if present.
-        scripts/railway/domains.sh client-unalias "$PREVIEW_CLIENT_HOST"
-        scripts/railway/domains.sh railway-domain "$PREVIEW_CLIENT_HOST" "$RAILWAY_PR_ENVIRONMENT" neo
-        echo "ok=1" >> "$GITHUB_OUTPUT"
+        [ -n "$neo_host" ] || { echo "No Railway domain for neo" >&2; exit 1; }
+        una_url="https://${una_host#https://}"
+        neo_url="https://${neo_host#https://}"
+        railway variable set --service una --environment "$RAILWAY_PR_ENVIRONMENT" --skip-deploys UNA_CLIENT_URL="$neo_url"
+        scripts/railway/neo.sh vars "$RAILWAY_PR_ENVIRONMENT" "$una_url" "$neo_url"
 
   deploy:
     name: Deploy PR to Railway (una)
@@ -292,9 +282,6 @@ jobs:
       RAILWAY_API_TOKEN: ${{ secrets.RAILWAY_API_TOKEN }}
       RAILWAY_PROJECT_ID: ${{ secrets.RAILWAY_PROJECT_ID }}
       RAILWAY_PR_ENVIRONMENT: pr-${{ github.event.pull_request.number }}
-      PREVIEW_API_HOST: api-pr-${{ github.event.pull_request.number }}.unacms.app
-      PREVIEW_CLIENT_HOST: pr-${{ github.event.pull_request.number }}.unacms.app
-      DOMAINS_OK: ${{ needs.preview-env.outputs.domains }}
       UNA_RESULT: ${{ needs.deploy.result }}
       NEO_RESULT: ${{ needs.deploy-neo.result }}
       GH_TOKEN: ${{ github.token }}
@@ -319,13 +306,8 @@ jobs:
         state() { [ "$1" = success ] && echo "" || echo " (deploy: $1)"; }
 
         marker="<!-- railway-preview -->"
-        if [ "$DOMAINS_OK" = "1" ]; then
-          body=$(printf '%s\n**Preview**: https://%s (NEO web client%s, also %s)\n**API**: https://%s (UNA%s, also %s)\n\nRailway environment: `%s`. A new custom domain needs a few minutes for its certificate, and a fresh UNA install about 10.' \
-            "$marker" "$PREVIEW_CLIENT_HOST" "$(state "$NEO_RESULT")" "$neo_url" "$PREVIEW_API_HOST" "$(state "$UNA_RESULT")" "$una_url" "$RAILWAY_PR_ENVIRONMENT")
-        else
-          body=$(printf '%s\n**Preview**: %s (NEO web client%s)\n**API**: %s (UNA%s)\n\nRailway environment: `%s`' \
-            "$marker" "$neo_url" "$(state "$NEO_RESULT")" "$una_url" "$(state "$UNA_RESULT")" "$RAILWAY_PR_ENVIRONMENT")
-        fi
+        body=$(printf '%s\n**Preview**: %s (NEO web client%s)\n**API**: %s (UNA%s)\n\nRailway environment: `%s`. A fresh UNA install takes about 10 minutes.' \
+          "$marker" "$neo_url" "$(state "$NEO_RESULT")" "$una_url" "$(state "$UNA_RESULT")" "$RAILWAY_PR_ENVIRONMENT")
 
         comment_id=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \
           --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"${marker}\"))) | .id" \
@@ -493,6 +475,8 @@ jobs:
           .github/actions
           scripts/railway
 
+    # Previews created before they moved to Railway domains (2026-10-20) still have DNS records
+    # on unacms.app; this step can go once those PRs are closed.
     - name: Remove preview domains
       if: env.VERCEL_TOKEN != ''
       continue-on-error: true

+ 1 - 1
AGENTS.md

@@ -90,6 +90,6 @@ The pull request description must include the database migration SQL when the ch
 - Branch from `master` and open the PR here. Start the title of a NEO-only PR with `Neo:`.
 - Never commit to unacms/neo. It is a mirror that `.github/workflows/sync-neo.yml` pushes on every push to `master`, and client projects fork it.
 - UNA CI doesn't check `neo/`. Run `yarn typecheck` and `yarn lint` in `neo/` before you push.
-- CI deploys both halves to the `una-ci` Railway project: service `una` (UNA) and service `neo` (the NEO web client, built from `neo/` with `scripts/railway/neo/`). A PR gets `pr-<n>.unacms.app` (NEO) and `api-pr-<n>.unacms.app` (UNA); master deploys production. See `.github/workflows/ci.yml` and `scripts/railway/`.
+- CI deploys both halves to the `una-ci` Railway project: service `una` (UNA) and service `neo` (the NEO web client, built from `neo/` with `scripts/railway/neo/`). A PR gets `neo-pr-<n>.up.railway.app` (NEO) and `una-pr-<n>.up.railway.app` (UNA), Railway's own domains, so previews need no certificates of their own; master deploys production (`api.unacms.app`). See `.github/workflows/ci.yml` and `scripts/railway/`.
 - A UNA service that NEO calls must return JSON for guests too; see [`neo/.agents/skills/una-api/SKILL.md`](neo/.agents/skills/una-api/SKILL.md).
 

+ 2 - 2
modules/boonex/messenger/install/config.php

@@ -17,12 +17,12 @@ $aConfig = array(
 	'name' => 'bx_messenger',
 	'title' => 'Messenger',
 	'note' => 'Messenger, instant messages, chat',
-	'version' => '14.0.0.DEV',
+	'version' => '15.0.0.DEV',
 	'vendor' => 'BoonEx',
 	'help_url' => 'http://feed.una.io/?section={module_name}',
 
 	'compatible_with' => array(
-		'14.0.0'
+		'15.0.x'
 	),
 
 	/**

+ 2 - 2
modules/boonex/organizations/updates/15.0.4_15.0.5/install/config.php

@@ -24,9 +24,9 @@ $aConfig = array(
     'home_uri' => 'orgs_update_1504_1505',
 
     'module_dir' => 'boonex/organizations/',
-    'module_uri' => 'organizations',
+    'module_uri' => 'orgs',
 
-    'db_prefix' => 'bx_orgs_',
+    'db_prefix' => 'bx_organizations_',
     'class_prefix' => 'BxOrgs',
 
     /**

+ 8 - 6
neo/packages/app/components/nav/menu-footer.js

@@ -13,9 +13,9 @@ export const MENU_FOOTER_CLASSES = 'flex w-full items-center border-t border-bor
 // Settings use the historical misspelling; keep this key aligned with settings/layout.js.
 const AVAILABLE_LANGS_SETTING_KEY = 'avaliable_langs';
 const FOOTER_THEME_ITEMS = [
-    { id: 'auto', titleKey: 'theme_footer_auto', icon: 'Eclipse' },
-    { id: 'light', titleKey: 'theme_footer_light', icon: 'Sun' },
-    { id: 'dark', titleKey: 'theme_footer_dark', icon: 'Moon' },
+    { id: 'auto', titleKey: 'theme_footer_auto', labelKey: 'theme_level_auto', icon: 'Eclipse' },
+    { id: 'light', titleKey: 'theme_footer_light', labelKey: 'theme_level_light', icon: 'Sun' },
+    { id: 'dark', titleKey: 'theme_footer_dark', labelKey: 'theme_level_dark', icon: 'Moon' },
 ];
 
 const linkVariantToNeoStyle = {
@@ -87,16 +87,17 @@ function FooterThemeSwitcher({ visualProps }) {
         }))
     ), [currentThemeName, t]);
     const currentThemeItem = FOOTER_THEME_ITEMS.find((theme) => theme.id === currentThemeName) ?? FOOTER_THEME_ITEMS[0];
-    const themeLabel = t(currentThemeItem.titleKey);
+    const themeLabel = t(currentThemeItem.labelKey);
 
+    // Icon only: the icon shows the current theme, and the menu names each one.
     return (
         <DropdownMenu
             items={items}
             onSelect={(item) => setThemeName(item.id)}
             buttonProps={{
-                label: themeLabel,
                 image: currentThemeItem.icon,
                 accessibilityLabel: themeLabel,
+                tooltip: themeLabel,
                 style: visualProps.style,
                 controlSize: visualProps.controlSize,
                 className: visualProps.className,
@@ -123,14 +124,15 @@ function FooterSoundsLevel({ visualProps }) {
     const current = SOUND_LEVEL_ITEMS.find((option) => option.id === level) ?? SOUND_LEVEL_ITEMS[0];
     const label = t(current.labelKey);
 
+    // Icon only, like the theme switcher: the icon shows the level, and the menu names each one.
     return (
         <DropdownMenu
             items={items}
             onSelect={(item) => setLevel(item.id)}
             buttonProps={{
-                label,
                 image: current.icon,
                 accessibilityLabel: label,
+                tooltip: label,
                 style: visualProps.style,
                 controlSize: visualProps.controlSize,
                 className: visualProps.className,

+ 3 - 0
neo/packages/app/default/translations/de.json

@@ -43,6 +43,9 @@
     "theme_footer_auto": "Auto",
     "theme_footer_light": "Hell",
     "theme_footer_dark": "Dunkel",
+    "theme_level_auto": "Design: automatisch",
+    "theme_level_light": "Design: hell",
+    "theme_level_dark": "Design: dunkel",
     "Sounds": "Töne",
     "sounds_full": "Laut",
     "sounds_quiet": "Leise",

+ 3 - 0
neo/packages/app/default/translations/en.json

@@ -43,6 +43,9 @@
     "theme_footer_auto": "Auto",
     "theme_footer_light": "Light",
     "theme_footer_dark": "Dark",
+    "theme_level_auto": "Theme: Auto",
+    "theme_level_light": "Theme: Light",
+    "theme_level_dark": "Theme: Dark",
     "Sounds": "Sounds",
     "sounds_full": "Full",
     "sounds_quiet": "Quiet",

+ 3 - 0
neo/packages/app/default/translations/es.json

@@ -43,6 +43,9 @@
     "theme_footer_auto": "Auto",
     "theme_footer_light": "Claro",
     "theme_footer_dark": "Oscuro",
+    "theme_level_auto": "Tema: automático",
+    "theme_level_light": "Tema: claro",
+    "theme_level_dark": "Tema: oscuro",
     "Sounds": "Sonidos",
     "sounds_full": "Alto",
     "sounds_quiet": "Bajo",

+ 3 - 0
neo/packages/app/default/translations/fr.json

@@ -43,6 +43,9 @@
     "theme_footer_auto": "Auto",
     "theme_footer_light": "Clair",
     "theme_footer_dark": "Sombre",
+    "theme_level_auto": "Thème : automatique",
+    "theme_level_light": "Thème : clair",
+    "theme_level_dark": "Thème : sombre",
     "Sounds": "Sons",
     "sounds_full": "Fort",
     "sounds_quiet": "Doux",

+ 3 - 0
neo/packages/app/default/translations/ru.json

@@ -43,6 +43,9 @@
     "theme_footer_auto": "Авто",
     "theme_footer_light": "Светлая",
     "theme_footer_dark": "Тёмная",
+    "theme_level_auto": "Тема: авто",
+    "theme_level_light": "Тема: светлая",
+    "theme_level_dark": "Тема: тёмная",
     "Sounds": "Звуки",
     "sounds_full": "Громко",
     "sounds_quiet": "Тихо",

+ 1 - 1
scripts/railway/seed.php

@@ -153,7 +153,7 @@ else
 $aPosts = [
     ['Hello from the demo site', "This site is a fresh UNA install deployed from the master branch. Everything here is sample content."],
     ['What NEO is', "NEO is the universal UNA client: one React Native codebase for web, iOS and Android, talking to UNA through its API."],
-    ['Preview environments', "Every pull request to UNA gets its own backend and client on unacms.app, created by CI and removed when the PR closes."],
+    ['Preview environments', "Every pull request to UNA gets its own backend and client on Railway, created by CI and removed when the PR closes."],
     ['Weekend reading', "A few articles about building communities that stay useful: keep threads short, answer quickly, and welcome new members by name."],
     ['Photo walk this Saturday', "Meeting at the old harbour at 10:00. Bring any camera; phones are fine. Coffee afterwards."],
     ['Tips for a good profile', "Add a picture, write two sentences about what you do, and follow a few groups. That's enough to get a useful feed."],

+ 63 - 0
tests/units/install/ModuleUpdatePacksTest.php

@@ -0,0 +1,63 @@
+<?php
+
+/**
+ * Module update packs (modules/<vendor>/<module>/updates/<from>_<to>/) must name the module they update the way the
+ * installed module is recorded. BxDolStudioUpdater finds the module by `module_dir` and `module_uri`, and builds the
+ * updater class name from `class_prefix`, so a pack that differs from the module's own config fails on every site.
+ */
+class ModuleUpdatePacksTest extends \PHPUnit\Framework\TestCase
+{
+    private static function modulesPath(): string
+    {
+        return __DIR__ . '/../../../modules/';
+    }
+
+    private static function readConfig(string $sPath): array
+    {
+        $aConfig = null;
+        include($sPath);
+        if (!is_array($aConfig))
+            self::fail(substr($sPath, strlen(self::modulesPath())) . ' does not define $aConfig.');
+
+        return $aConfig;
+    }
+
+    public function testPacksMatchTheirModule(): void
+    {
+        $sModules = self::modulesPath();
+        $aPacks = glob($sModules . '*/*/updates/*/install/config.php');
+        $this->assertNotEmpty($aPacks, 'The repository has module update packs.');
+
+        $aModules = [];
+        $aErrors = [];
+        foreach ($aPacks as $sPath) {
+            $sPack = substr(dirname($sPath, 2), strlen($sModules)) . '/';
+            $aPack = self::readConfig($sPath);
+
+            $sModuleDir = $aPack['module_dir'] ?? '';
+            if (strpos($sPack, $sModuleDir . 'updates/') !== 0) {
+                $aErrors[] = "$sPack: module_dir '$sModuleDir' is not the module the pack is in";
+                continue;
+            }
+
+            if (!isset($aModules[$sModuleDir]))
+                $aModules[$sModuleDir] = self::readConfig($sModules . $sModuleDir . 'install/config.php');
+            $aModule = $aModules[$sModuleDir];
+
+            if (($aPack['module_uri'] ?? '') !== $aModule['home_uri'])
+                $aErrors[] = "$sPack: module_uri '" . ($aPack['module_uri'] ?? '') . "' is not the module's home_uri '{$aModule['home_uri']}'";
+
+            if (($aPack['class_prefix'] ?? '') !== $aModule['class_prefix'])
+                $aErrors[] = "$sPack: class_prefix '" . ($aPack['class_prefix'] ?? '') . "' is not the module's '{$aModule['class_prefix']}'";
+
+            $sVersions = ($aPack['version_from'] ?? '') . '_' . ($aPack['version_to'] ?? '');
+            if (basename($sPack) !== $sVersions)
+                $aErrors[] = "$sPack: the folder is not named after version_from and version_to ($sVersions)";
+
+            if (($aPack['home_dir'] ?? '') !== $sModuleDir . 'updates/update_' . $sVersions . '/')
+                $aErrors[] = "$sPack: home_dir '" . ($aPack['home_dir'] ?? '') . "' is not '{$sModuleDir}updates/update_{$sVersions}/'";
+        }
+
+        $this->assertSame([], $aErrors, "Update packs that would not apply:\n" . implode("\n", $aErrors));
+    }
+}