0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-02-21 11:28:12 +01:00

Merge d82c0ee3fb5d400cbfcb29af19507d7267f40101 into 87f729190918e957b1d80c5e94c4e3ff440a387c

This commit is contained in:
Nicolas 2026-02-20 16:40:11 +01:00 committed by GitHub
commit c4b8171af6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 626 additions and 2 deletions

View File

@ -4,6 +4,7 @@
package setting
import (
"context"
"strings"
"sync"
@ -53,9 +54,43 @@ type RepositoryStruct struct {
GitGuideRemoteName *config.Value[string]
}
type InstanceNotice struct {
Enabled bool
Message string
StartTime int64
EndTime int64
}
func DefaultInstanceNotice() InstanceNotice {
return InstanceNotice{}
}
func (n *InstanceNotice) IsActive(now int64) bool {
if !n.Enabled || n.Message == "" {
return false
}
if n.StartTime > 0 && now < n.StartTime {
return false
}
if n.EndTime > 0 && now > n.EndTime {
return false
}
return true
}
func GetInstanceNotice(ctx context.Context) InstanceNotice {
return Config().InstanceNotice.Banner.Value(ctx)
}
type InstanceNoticeStruct struct {
Banner *config.Value[InstanceNotice]
}
type ConfigStruct struct {
Picture *PictureStruct
Repository *RepositoryStruct
Picture *PictureStruct
Repository *RepositoryStruct
InstanceNotice *InstanceNoticeStruct
}
var (
@ -74,6 +109,9 @@ func initDefaultConfig() {
OpenWithEditorApps: config.ValueJSON[OpenWithEditorAppsType]("repository.open-with.editor-apps"),
GitGuideRemoteName: config.ValueJSON[string]("repository.git-guide-remote-name").WithDefault("origin"),
},
InstanceNotice: &InstanceNoticeStruct{
Banner: config.ValueJSON[InstanceNotice]("instance.notice").WithDefault(DefaultInstanceNotice()),
},
}
}

View File

@ -3277,6 +3277,24 @@
"admin.config.cache_test_failed": "Failed to probe the cache: %v.",
"admin.config.cache_test_slow": "Cache test successful, but response is slow: %s.",
"admin.config.cache_test_succeeded": "Cache test successful, got a response in %s.",
"admin.config.instance_notice": "Instance Banner",
"admin.config.instance_notice.settings": "Settings",
"admin.config.instance_notice.enabled": "Enable banner",
"admin.config.instance_notice.message": "Message",
"admin.config.instance_notice.message_placeholder": "This message supports Markdown.",
"admin.config.instance_notice.message_required": "Message is required when the banner is enabled.",
"admin.config.instance_notice.message_too_long": "Message must be at most %d characters.",
"admin.config.instance_notice.start_time": "Start time",
"admin.config.instance_notice.end_time": "End time",
"admin.config.instance_notice.invalid_time": "Invalid date-time value. Use the picker format.",
"admin.config.instance_notice.invalid_time_range": "End time must be after start time.",
"admin.config.instance_notice.preview": "Preview",
"admin.config.instance_notice.save": "Save banner",
"admin.config.instance_notice.save_success": "Instance banner settings updated.",
"admin.config.instance_notice.delete": "Delete banner",
"admin.config.instance_notice.delete_success": "Instance banner deleted.",
"admin.config.instance_notice.edit_hint": "Edit this banner",
"admin.config.instance_notice.dismiss": "Dismiss",
"admin.config.session_config": "Session Configuration",
"admin.config.session_provider": "Session Provider",
"admin.config.provider_config": "Provider Config",

View File

@ -5,6 +5,8 @@ package common
import (
goctx "context"
"crypto/sha256"
"encoding/hex"
"errors"
"sync"
@ -12,6 +14,8 @@ import (
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/services/context"
)
@ -69,14 +73,35 @@ type pageGlobalDataType struct {
IsSigned bool
IsSiteAdmin bool
InstanceNoticeBanner *InstanceNoticeBannerTmplInfo
GetNotificationUnreadCount func() int64
GetActiveStopwatch func() *StopwatchTmplInfo
}
type InstanceNoticeBannerTmplInfo struct {
Message string
DismissKey string
}
func getInstanceNoticeBanner(ctx *context.Context) *InstanceNoticeBannerTmplInfo {
notice := setting.GetInstanceNotice(ctx)
if !notice.IsActive(int64(timeutil.TimeStampNow())) {
return nil
}
h := sha256.Sum256([]byte(notice.Message))
dismissKey := hex.EncodeToString(h[:])[:16]
return &InstanceNoticeBannerTmplInfo{
Message: notice.Message,
DismissKey: dismissKey,
}
}
func PageGlobalData(ctx *context.Context) {
var data pageGlobalDataType
data.IsSigned = ctx.Doer != nil
data.IsSiteAdmin = ctx.Doer != nil && ctx.Doer.IsAdmin
data.InstanceNoticeBanner = getInstanceNoticeBanner(ctx)
data.GetNotificationUnreadCount = sync.OnceValue(func() int64 { return notificationUnreadCount(ctx) })
data.GetActiveStopwatch = sync.OnceValue(func() *StopwatchTmplInfo { return getActiveStopwatch(ctx) })
ctx.Data["PageGlobalData"] = data

View File

@ -9,6 +9,8 @@ import (
"net/url"
"strconv"
"strings"
"time"
"unicode/utf8"
system_model "code.gitea.io/gitea/models/system"
"code.gitea.io/gitea/modules/cache"
@ -28,6 +30,8 @@ import (
const (
tplConfig templates.TplName = "admin/config"
tplConfigSettings templates.TplName = "admin/config_settings/config_settings"
instanceNoticeMessageMaxLength = 2000
)
// SendTestMail send test mail to confirm mail service is OK
@ -145,6 +149,15 @@ func Config(ctx *context.Context) {
ctx.Data["Service"] = setting.Service
ctx.Data["DbCfg"] = setting.Database
ctx.Data["Webhook"] = setting.Webhook
instanceNotice := setting.GetInstanceNotice(ctx)
ctx.Data["InstanceNotice"] = instanceNotice
ctx.Data["InstanceNoticeMessageMaxLength"] = instanceNoticeMessageMaxLength
if instanceNotice.StartTime > 0 {
ctx.Data["InstanceNoticeStartTime"] = time.Unix(instanceNotice.StartTime, 0).In(setting.DefaultUILocation).Format("2006-01-02T15:04")
}
if instanceNotice.EndTime > 0 {
ctx.Data["InstanceNoticeEndTime"] = time.Unix(instanceNotice.EndTime, 0).In(setting.DefaultUILocation).Format("2006-01-02T15:04")
}
ctx.Data["MailerEnabled"] = false
if setting.MailService != nil {
@ -187,6 +200,89 @@ func Config(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplConfig)
}
func parseDatetimeLocalValue(raw string) (int64, error) {
if raw == "" {
return 0, nil
}
tm, err := time.ParseInLocation("2006-01-02T15:04", raw, setting.DefaultUILocation)
if err != nil {
return 0, err
}
return tm.Unix(), nil
}
func SetInstanceNotice(ctx *context.Context) {
saveInstanceNotice := func(instanceNotice setting.InstanceNotice) {
marshaled, err := json.Marshal(instanceNotice)
if err != nil {
ctx.ServerError("Marshal", err)
return
}
if err := system_model.SetSettings(ctx, map[string]string{
setting.Config().InstanceNotice.Banner.DynKey(): string(marshaled),
}); err != nil {
ctx.ServerError("SetSettings", err)
return
}
config.GetDynGetter().InvalidateCache()
}
if ctx.FormString("action") == "delete" {
saveInstanceNotice(setting.DefaultInstanceNotice())
if ctx.Written() {
return
}
ctx.Flash.Success(ctx.Tr("admin.config.instance_notice.delete_success"))
ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice")
return
}
enabled := ctx.FormBool("enabled")
message := strings.TrimSpace(ctx.FormString("message"))
startTime, err := parseDatetimeLocalValue(strings.TrimSpace(ctx.FormString("start_time")))
if err != nil {
ctx.Flash.Error(ctx.Tr("admin.config.instance_notice.invalid_time"))
ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice")
return
}
endTime, err := parseDatetimeLocalValue(strings.TrimSpace(ctx.FormString("end_time")))
if err != nil {
ctx.Flash.Error(ctx.Tr("admin.config.instance_notice.invalid_time"))
ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice")
return
}
if enabled && message == "" {
ctx.Flash.Error(ctx.Tr("admin.config.instance_notice.message_required"))
ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice")
return
}
if utf8.RuneCountInString(message) > instanceNoticeMessageMaxLength {
ctx.Flash.Error(ctx.Tr("admin.config.instance_notice.message_too_long", instanceNoticeMessageMaxLength))
ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice")
return
}
if startTime > 0 && endTime > 0 && endTime < startTime {
ctx.Flash.Error(ctx.Tr("admin.config.instance_notice.invalid_time_range"))
ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice")
return
}
instanceNotice := setting.InstanceNotice{
Enabled: enabled,
Message: message,
StartTime: startTime,
EndTime: endTime,
}
saveInstanceNotice(instanceNotice)
if ctx.Written() {
return
}
ctx.Flash.Success(ctx.Tr("admin.config.instance_notice.save_success"))
ctx.Redirect(setting.AppSubURL + "/-/admin/config#instance-notice")
}
func ConfigSettings(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.config_settings")
ctx.Data["PageIsAdminConfig"] = true

View File

@ -717,6 +717,7 @@ func registerWebRoutes(m *web.Router) {
m.Group("/config", func() {
m.Get("", admin.Config)
m.Post("", admin.ChangeConfig)
m.Post("/instance_notice", admin.SetInstanceNotice)
m.Post("/test_mail", admin.SendTestMail)
m.Post("/test_cache", admin.TestCache)
m.Get("/settings", admin.ConfigSettings)

View File

@ -265,6 +265,53 @@
</dl>
</div>
<h4 id="instance-notice" class="ui top attached header">
{{ctx.Locale.Tr "admin.config.instance_notice"}}
</h4>
<div class="ui attached table segment">
<dl class="admin-dl-horizontal">
<dt class="tw-py-1 tw-flex tw-items-center">{{ctx.Locale.Tr "admin.config.instance_notice.settings"}}</dt>
<dd class="tw-py-0">
<form class="ui form ignore-dirty" action="{{AppSubUrl}}/-/admin/config/instance_notice" method="post">
<div class="field">
<div class="ui checkbox">
<input type="checkbox" name="enabled" value="true" {{if .InstanceNotice.Enabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "admin.config.instance_notice.enabled"}}</label>
</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.config.instance_notice.message"}}</label>
<textarea name="message" rows="4" maxlength="{{.InstanceNoticeMessageMaxLength}}" placeholder="{{ctx.Locale.Tr "admin.config.instance_notice.message_placeholder"}}">{{.InstanceNotice.Message}}</textarea>
</div>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "admin.config.instance_notice.start_time"}}</label>
<input type="datetime-local" name="start_time" value="{{.InstanceNoticeStartTime}}">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.config.instance_notice.end_time"}}</label>
<input type="datetime-local" name="end_time" value="{{.InstanceNoticeEndTime}}">
</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.config.instance_notice.preview"}}</label>
<div id="instance-notice-preview" class="ui info message tw-mt-2">
<div class="tw-flex tw-items-center tw-justify-center">
<div id="instance-notice-preview-content" class="render-content markup tw-text-center">{{ctx.RenderUtils.MarkdownToHtml .InstanceNotice.Message}}</div>
</div>
</div>
</div>
<button class="ui tiny primary button">{{ctx.Locale.Tr "admin.config.instance_notice.save"}}</button>
</form>
<form class="ui form ignore-dirty tw-mt-3" action="{{AppSubUrl}}/-/admin/config/instance_notice" method="post">
<input type="hidden" name="action" value="delete">
<button class="ui tiny button">{{ctx.Locale.Tr "admin.config.instance_notice.delete"}}</button>
</form>
</dd>
</dl>
</div>
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.config.session_config"}}
</h4>

View File

@ -176,3 +176,19 @@
</div>
{{end}}
</nav>
{{if and .PageGlobalData .PageGlobalData.InstanceNoticeBanner}}
{{$banner := .PageGlobalData.InstanceNoticeBanner}}
<div id="instance-notice-banner" class="ui info attached message tw-m-0 tw-rounded-none" data-dismiss-key="{{$banner.DismissKey}}">
<div class="tw-flex tw-items-center tw-justify-center tw-gap-3">
<div class="render-content markup tw-text-center tw-flex-1">{{ctx.RenderUtils.MarkdownToHtml $banner.Message}}</div>
<button type="button" class="ui mini icon button instance-notice-dismiss tw-shrink-0" aria-label="{{ctx.Locale.Tr "admin.config.instance_notice.dismiss"}}">
{{svg "octicon-x"}}
</button>
</div>
{{if .PageGlobalData.IsSiteAdmin}}
<div class="tw-mt-2 tw-text-center">
<a href="{{AppSubUrl}}/-/admin/config#instance-notice">{{ctx.Locale.Tr "admin.config.instance_notice.edit_hint"}}</a>
</div>
{{end}}
</div>
{{end}}

View File

@ -0,0 +1,121 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"strings"
"testing"
"time"
system_model "code.gitea.io/gitea/models/system"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/setting/config"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInstanceNoticeVisibility(t *testing.T) {
defer tests.PrepareTestEnv(t)()
setInstanceNoticeForTest(t, setting.DefaultInstanceNotice())
setInstanceNoticeForTest(t, setting.InstanceNotice{
Enabled: true,
Message: "Planned **upgrade** in progress.",
})
t.Run("AnonymousUserSeesBanner", func(t *testing.T) {
resp := MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK)
assert.Contains(t, resp.Body.String(), "Planned <strong>upgrade</strong> in progress.")
})
t.Run("NormalUserSeesBanner", func(t *testing.T) {
sess := loginUser(t, "user2")
resp := sess.MakeRequest(t, NewRequest(t, "GET", "/user/settings"), http.StatusOK)
assert.Contains(t, resp.Body.String(), "Planned <strong>upgrade</strong> in progress.")
})
t.Run("AdminSeesBannerAndEditHint", func(t *testing.T) {
sess := loginUser(t, "user1")
resp := sess.MakeRequest(t, NewRequest(t, "GET", "/-/admin"), http.StatusOK)
assert.Contains(t, resp.Body.String(), "Planned <strong>upgrade</strong> in progress.")
assert.Contains(t, resp.Body.String(), "Edit this banner")
})
t.Run("APIRequestUnchanged", func(t *testing.T) {
MakeRequest(t, NewRequest(t, "GET", "/api/v1/version"), http.StatusOK)
})
}
func TestInstanceNoticeTimeWindow(t *testing.T) {
defer tests.PrepareTestEnv(t)()
setInstanceNoticeForTest(t, setting.DefaultInstanceNotice())
now := time.Now().Unix()
setInstanceNoticeForTest(t, setting.InstanceNotice{
Enabled: true,
Message: "Future banner",
StartTime: now + 3600,
EndTime: now + 7200,
})
resp := MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK)
assert.NotContains(t, resp.Body.String(), "Future banner")
setInstanceNoticeForTest(t, setting.InstanceNotice{
Enabled: true,
Message: "Expired banner",
StartTime: now - 7200,
EndTime: now - 3600,
})
resp = MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK)
assert.NotContains(t, resp.Body.String(), "Expired banner")
}
func TestInstanceNoticeAdminCRUD(t *testing.T) {
defer tests.PrepareTestEnv(t)()
setInstanceNoticeForTest(t, setting.DefaultInstanceNotice())
adminSession := loginUser(t, "user1")
req := NewRequestWithValues(t, "POST", "/-/admin/config/instance_notice", map[string]string{
"enabled": "true",
"message": "Admin set banner",
})
adminSession.MakeRequest(t, req, http.StatusSeeOther)
notice := setting.GetInstanceNotice(t.Context())
assert.True(t, notice.Enabled)
assert.Equal(t, "Admin set banner", notice.Message)
req = NewRequestWithValues(t, "POST", "/-/admin/config/instance_notice", map[string]string{
"enabled": "true",
"message": strings.Repeat("a", 2001),
})
adminSession.MakeRequest(t, req, http.StatusSeeOther)
notice = setting.GetInstanceNotice(t.Context())
assert.Equal(t, "Admin set banner", notice.Message)
req = NewRequestWithValues(t, "POST", "/-/admin/config/instance_notice", map[string]string{
"action": "delete",
})
adminSession.MakeRequest(t, req, http.StatusSeeOther)
notice = setting.GetInstanceNotice(t.Context())
assert.Equal(t, setting.DefaultInstanceNotice(), notice)
}
func setInstanceNoticeForTest(t *testing.T, notice setting.InstanceNotice) {
t.Helper()
marshaled, err := json.Marshal(notice)
require.NoError(t, err)
require.NoError(t, system_model.SetSettings(t.Context(), map[string]string{
setting.Config().InstanceNotice.Banner.DynKey(): string(marshaled),
}))
config.GetDynGetter().InvalidateCache()
}

View File

@ -0,0 +1,104 @@
import {beforeEach, describe, expect, test, vi} from 'vitest';
import {initAdminConfigs} from './config.ts';
import {POST} from '../../modules/fetch.ts';
vi.mock('../../modules/fetch.ts', () => ({
POST: vi.fn(),
}));
vi.mock('../../modules/tippy.ts', () => ({
showTemporaryTooltip: vi.fn(),
}));
function createPreviewDOM() {
document.body.innerHTML = `
<div class="page-content admin config">
<form class="ui form" action="/-/admin/config/instance_notice" method="post">
<textarea name="message">Initial message</textarea>
</form>
<div id="instance-notice-preview" class="ui info message">
<div id="instance-notice-preview-content"></div>
</div>
</div>
`;
}
describe('Admin Instance Notice Preview', () => {
beforeEach(() => {
vi.clearAllMocks();
createPreviewDOM();
});
test('renders markdown preview on input', async () => {
vi.mocked(POST).mockResolvedValue({
text: async () => '<p>Rendered message</p>',
} as Response);
initAdminConfigs();
const messageInput = document.querySelector<HTMLTextAreaElement>('textarea[name="message"]')!;
messageInput.value = 'Updated message';
messageInput.dispatchEvent(new Event('input'));
await Promise.resolve();
await Promise.resolve();
expect(POST).toHaveBeenCalledWith('/-/markup', expect.objectContaining({
data: expect.any(FormData),
}));
const formData = vi.mocked(POST).mock.calls[0][1]?.data as FormData;
expect(formData.get('mode')).toBe('comment');
expect(formData.get('text')).toBe('Updated message');
const previewContent = document.querySelector('#instance-notice-preview-content')!;
expect(previewContent.innerHTML).toContain('Rendered message');
});
test('queues a second render while first request is in flight and re-renders with latest text', async () => {
let firstResolve: ((value: Response) => void) | undefined;
const firstPending = new Promise<Response>((resolve) => {
firstResolve = resolve;
});
vi.mocked(POST)
.mockImplementationOnce(async () => await firstPending)
.mockResolvedValueOnce({
text: async () => '<p>Second render</p>',
} as Response);
initAdminConfigs();
const messageInput = document.querySelector<HTMLTextAreaElement>('textarea[name="message"]')!;
messageInput.value = 'First value';
messageInput.dispatchEvent(new Event('input'));
await Promise.resolve();
messageInput.value = 'Latest value';
messageInput.dispatchEvent(new Event('input'));
firstResolve?.({
text: async () => '<p>First render</p>',
} as Response);
for (let i = 0; i < 10 && vi.mocked(POST).mock.calls.length < 2; i++) {
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
expect(POST).toHaveBeenCalledTimes(2);
const secondData = vi.mocked(POST).mock.calls[1][1]?.data as FormData;
expect(secondData.get('text')).toBe('Latest value');
const previewContent = document.querySelector('#instance-notice-preview-content')!;
for (let i = 0; i < 10 && !previewContent.innerHTML.includes('Second render'); i++) {
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
expect(previewContent.innerHTML).toContain('Second render');
});
});

View File

@ -1,8 +1,50 @@
import {showTemporaryTooltip} from '../../modules/tippy.ts';
import {POST} from '../../modules/fetch.ts';
import {html, htmlRaw} from '../../utils/html.ts';
const {appSubUrl} = window.config;
function initInstanceNoticePreview(elAdminConfig: HTMLDivElement): void {
const form = elAdminConfig.querySelector<HTMLFormElement>('form[action$="/-/admin/config/instance_notice"]');
if (!form) return;
const inputMessage = form.querySelector<HTMLTextAreaElement>('textarea[name="message"]');
const previewContent = elAdminConfig.querySelector<HTMLDivElement>('#instance-notice-preview-content');
if (!inputMessage || !previewContent) return;
let renderRequesting = false;
let pendingRender = false;
const renderPreviewMarkdown = async () => {
if (renderRequesting) {
pendingRender = true;
return;
}
renderRequesting = true;
try {
while (true) {
pendingRender = false;
const formData = new FormData();
formData.append('mode', 'comment');
formData.append('text', inputMessage.value);
try {
const response = await POST(`${appSubUrl}/-/markup`, {data: formData});
const rendered = await response.text();
previewContent.innerHTML = html`${htmlRaw(rendered)}`;
} catch (error) {
console.error('Error rendering instance notice preview:', error);
}
if (!pendingRender) break;
}
} finally {
renderRequesting = false;
}
};
inputMessage.addEventListener('input', () => {
renderPreviewMarkdown();
});
}
export function initAdminConfigs(): void {
const elAdminConfig = document.querySelector<HTMLDivElement>('.page-content.admin.config');
if (!elAdminConfig) return;
@ -21,4 +63,6 @@ export function initAdminConfigs(): void {
}
});
}
initInstanceNoticePreview(elAdminConfig);
}

View File

@ -0,0 +1,84 @@
import {beforeEach, describe, expect, test, vi} from 'vitest';
import {initInstanceNotice} from './instance-notice.ts';
import {localUserSettings} from '../modules/user-settings.ts';
vi.mock('../modules/user-settings.ts', () => ({
localUserSettings: {
getString: vi.fn(),
setString: vi.fn(),
},
}));
function createBannerDOM(dismissKey: string) {
document.body.innerHTML = `
<div id="instance-notice-banner" class="ui info attached message" data-dismiss-key="${dismissKey}">
<div class="tw-flex tw-items-center tw-justify-center tw-gap-3">
<div class="render-content markup tw-text-center tw-flex-1">Maintenance in progress</div>
<button type="button" class="ui mini icon button instance-notice-dismiss">X</button>
</div>
</div>
`;
}
describe('Instance Notice Banner Dismiss', () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = '';
});
test('no banner in DOM does nothing', () => {
initInstanceNotice();
expect(localUserSettings.getString).not.toHaveBeenCalled();
});
test('hides banner when dismiss key matches stored value', () => {
createBannerDOM('abc123');
vi.mocked(localUserSettings.getString).mockReturnValue('abc123');
initInstanceNotice();
const banner = document.querySelector<HTMLElement>('#instance-notice-banner')!;
expect(banner.style.display).toBe('none');
expect(localUserSettings.setString).not.toHaveBeenCalled();
});
test('does not hide banner when stored key differs', () => {
createBannerDOM('abc123');
vi.mocked(localUserSettings.getString).mockReturnValue('old-key');
initInstanceNotice();
const banner = document.querySelector<HTMLElement>('#instance-notice-banner')!;
expect(banner.style.display).not.toBe('none');
});
test('clicking dismiss button stores key and hides banner', () => {
createBannerDOM('abc123');
vi.mocked(localUserSettings.getString).mockReturnValue('');
initInstanceNotice();
const banner = document.querySelector<HTMLElement>('#instance-notice-banner')!;
expect(banner.style.display).not.toBe('none');
const dismissBtn = banner.querySelector<HTMLButtonElement>('.instance-notice-dismiss')!;
dismissBtn.click();
expect(localUserSettings.setString).toHaveBeenCalledWith('instance_notice_dismissed', 'abc123');
expect(banner.style.display).toBe('none');
});
test('banner without data-dismiss-key does nothing', () => {
document.body.innerHTML = `
<div id="instance-notice-banner" class="ui info attached message">
<div>Some message</div>
</div>
`;
initInstanceNotice();
const banner = document.querySelector<HTMLElement>('#instance-notice-banner')!;
expect(banner.style.display).not.toBe('none');
expect(localUserSettings.getString).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,28 @@
import {localUserSettings} from '../modules/user-settings.ts';
const DISMISSED_KEY = 'instance_notice_dismissed';
function hideBanner(el: HTMLElement) {
el.style.display = 'none';
}
export function initInstanceNotice(): void {
const banner = document.querySelector<HTMLElement>('#instance-notice-banner');
if (!banner) return;
const dismissKey = banner.getAttribute('data-dismiss-key');
if (!dismissKey) return;
if (localUserSettings.getString(DISMISSED_KEY, '') === dismissKey) {
hideBanner(banner);
return;
}
const dismissBtn = banner.querySelector<HTMLButtonElement>('.instance-notice-dismiss');
if (dismissBtn) {
dismissBtn.addEventListener('click', () => {
localUserSettings.setString(DISMISSED_KEY, dismissKey);
hideBanner(banner);
});
}
}

View File

@ -64,6 +64,7 @@ import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton}
import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
import {callInitFunctions} from './modules/init.ts';
import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
import {initInstanceNotice} from './features/instance-notice.ts';
const initStartTime = performance.now();
const initPerformanceTracer = callInitFunctions([
@ -75,6 +76,7 @@ const initPerformanceTracer = callInitFunctions([
initGlobalDropdown,
initGlobalFetchAction,
initGlobalTooltips,
initInstanceNotice,
initGlobalButtonClickOnEnter,
initGlobalButtons,
initGlobalCopyToClipboardListener,