Merge branch 'release/v1.22' into backport/30660-to-1.22

This commit is contained in:
Giteabot 2024-05-01 09:43:49 +08:00 committed by GitHub
commit 94c1adb610
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 216 additions and 212 deletions

View File

@ -778,7 +778,7 @@ generate-backend: $(TAGS_PREREQ) generate-go
.PHONY: generate-go
generate-go: $(TAGS_PREREQ)
@echo "Running go generate..."
@CC= GOOS= GOARCH= $(GO) generate -tags '$(TAGS)' ./...
@CC= GOOS= GOARCH= CGO_ENABLED=0 $(GO) generate -tags '$(TAGS)' ./...
.PHONY: security-check
security-check:

View File

@ -130,7 +130,10 @@ func GetRepoAssignees(ctx context.Context, repo *Repository) (_ []*user_model.Us
// and just waste 1 unit is cheaper than re-allocate memory once.
users := make([]*user_model.User, 0, len(uniqueUserIDs)+1)
if len(userIDs) > 0 {
if err = e.In("id", uniqueUserIDs.Values()).OrderBy(user_model.GetOrderByName()).Find(&users); err != nil {
if err = e.In("id", uniqueUserIDs.Values()).
Where(builder.Eq{"`user`.is_active": true}).
OrderBy(user_model.GetOrderByName()).
Find(&users); err != nil {
return nil, err
}
}
@ -152,7 +155,8 @@ func GetReviewers(ctx context.Context, repo *Repository, doerID, posterID int64)
return nil, err
}
cond := builder.And(builder.Neq{"`user`.id": posterID})
cond := builder.And(builder.Neq{"`user`.id": posterID}).
And(builder.Eq{"`user`.is_active": true})
if repo.IsPrivate || repo.Owner.Visibility == api.VisibleTypePrivate {
// This a private repository:

View File

@ -9,6 +9,7 @@ import (
"code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"github.com/stretchr/testify/assert"
)
@ -25,8 +26,17 @@ func TestRepoAssignees(t *testing.T) {
repo21 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 21})
users, err = repo_model.GetRepoAssignees(db.DefaultContext, repo21)
assert.NoError(t, err)
assert.Len(t, users, 4)
assert.ElementsMatch(t, []int64{10, 15, 16, 18}, []int64{users[0].ID, users[1].ID, users[2].ID, users[3].ID})
if assert.Len(t, users, 4) {
assert.ElementsMatch(t, []int64{10, 15, 16, 18}, []int64{users[0].ID, users[1].ID, users[2].ID, users[3].ID})
}
// do not return deactivated users
assert.NoError(t, user_model.UpdateUserCols(db.DefaultContext, &user_model.User{ID: 15, IsActive: false}, "is_active"))
users, err = repo_model.GetRepoAssignees(db.DefaultContext, repo21)
assert.NoError(t, err)
if assert.Len(t, users, 3) {
assert.NotContains(t, []int64{users[0].ID, users[1].ID, users[2].ID}, 15)
}
}
func TestRepoGetReviewers(t *testing.T) {
@ -38,17 +48,19 @@ func TestRepoGetReviewers(t *testing.T) {
ctx := db.DefaultContext
reviewers, err := repo_model.GetReviewers(ctx, repo1, 2, 2)
assert.NoError(t, err)
assert.Len(t, reviewers, 4)
if assert.Len(t, reviewers, 3) {
assert.ElementsMatch(t, []int64{1, 4, 11}, []int64{reviewers[0].ID, reviewers[1].ID, reviewers[2].ID})
}
// should include doer if doer is not PR poster.
reviewers, err = repo_model.GetReviewers(ctx, repo1, 11, 2)
assert.NoError(t, err)
assert.Len(t, reviewers, 4)
assert.Len(t, reviewers, 3)
// should not include PR poster, if PR poster would be otherwise eligible
reviewers, err = repo_model.GetReviewers(ctx, repo1, 11, 4)
assert.NoError(t, err)
assert.Len(t, reviewers, 3)
assert.Len(t, reviewers, 2)
// test private user repo
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})

View File

@ -121,29 +121,25 @@ func RenderIssueTitle(ctx context.Context, text string, metas map[string]string)
// RenderLabel renders a label
// locale is needed due to an import cycle with our context providing the `Tr` function
func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML {
var (
archivedCSSClass string
textColor = util.ContrastColor(label.Color)
labelScope = label.ExclusiveScope()
)
description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description))
var extraCSSClasses string
textColor := util.ContrastColor(label.Color)
labelScope := label.ExclusiveScope()
descriptionText := emoji.ReplaceAliases(label.Description)
if label.IsArchived() {
archivedCSSClass = "archived-label"
description = fmt.Sprintf("(%s) %s", locale.TrString("archived"), description)
extraCSSClasses = "archived-label"
descriptionText = fmt.Sprintf("(%s) %s", locale.TrString("archived"), descriptionText)
}
if labelScope == "" {
// Regular label
s := fmt.Sprintf("<div class='ui label %s' style='color: %s !important; background-color: %s !important;' data-tooltip-content title='%s'>%s</div>",
archivedCSSClass, textColor, label.Color, description, RenderEmoji(ctx, label.Name))
return template.HTML(s)
return HTMLFormat(`<div class="ui label %s" style="color: %s !important; background-color: %s !important;" data-tooltip-content title="%s">%s</div>`,
extraCSSClasses, textColor, label.Color, descriptionText, RenderEmoji(ctx, label.Name))
}
// Scoped label
scopeText := RenderEmoji(ctx, labelScope)
itemText := RenderEmoji(ctx, label.Name[len(labelScope)+1:])
scopeHTML := RenderEmoji(ctx, labelScope)
itemHTML := RenderEmoji(ctx, label.Name[len(labelScope)+1:])
// Make scope and item background colors slightly darker and lighter respectively.
// More contrast needed with higher luminance, empirically tweaked.
@ -171,14 +167,13 @@ func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_m
itemColor := "#" + hex.EncodeToString(itemBytes)
scopeColor := "#" + hex.EncodeToString(scopeBytes)
s := fmt.Sprintf("<span class='ui label %s scope-parent' data-tooltip-content title='%s'>"+
"<div class='ui label scope-left' style='color: %s !important; background-color: %s !important'>%s</div>"+
"<div class='ui label scope-right' style='color: %s !important; background-color: %s !important'>%s</div>"+
"</span>",
archivedCSSClass, description,
textColor, scopeColor, scopeText,
textColor, itemColor, itemText)
return template.HTML(s)
return HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+
`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+
`<div class="ui label scope-right" style="color: %s !important; background-color: %s !important">%s</div>`+
`</span>`,
extraCSSClasses, descriptionText,
textColor, scopeColor, scopeHTML,
textColor, itemColor, itemHTML)
}
// RenderEmoji renders html text with emoji post processors

26
package-lock.json generated
View File

@ -42,7 +42,6 @@
"postcss": "8.4.38",
"postcss-loader": "8.1.1",
"postcss-nesting": "12.1.2",
"pretty-ms": "9.0.0",
"sortablejs": "1.15.2",
"swagger-ui-dist": "5.17.2",
"tailwindcss": "3.4.3",
@ -9170,17 +9169,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-ms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
"integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -9772,20 +9760,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/pretty-ms": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.0.0.tgz",
"integrity": "sha512-E9e9HJ9R9NasGOgPaPE8VMeiPKAyWR5jcFpNnwIejslIhWqdqOrb2wShBsncMPUb+BcCd2OPYfh7p2W6oemTng==",
"dependencies": {
"parse-ms": "^4.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/printable-characters": {
"version": "1.0.42",
"resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz",

View File

@ -41,7 +41,6 @@
"postcss": "8.4.38",
"postcss-loader": "8.1.1",
"postcss-nesting": "12.1.2",
"pretty-ms": "9.0.0",
"sortablejs": "1.15.2",
"swagger-ui-dist": "5.17.2",
"tailwindcss": "3.4.3",

View File

@ -2177,7 +2177,10 @@ func GetIssueInfo(ctx *context.Context) {
}
}
ctx.JSON(http.StatusOK, convert.ToIssue(ctx, ctx.Doer, issue))
ctx.JSON(http.StatusOK, map[string]any{
"convertedIssue": convert.ToIssue(ctx, ctx.Doer, issue),
"renderedLabels": templates.RenderLabels(ctx, ctx.Locale, issue.Labels, ctx.Repo.RepoLink, issue),
})
}
// UpdateIssueTitle change issue's title

View File

@ -12,6 +12,14 @@
<!-- mobile right menu, it must be here because in mobile view, each item is a flex column, the first item is a full row column -->
<div class="ui secondary menu item navbar-mobile-right only-mobile">
{{if and .IsSigned EnableTimetracking .ActiveStopwatch}}
<a id="mobile-stopwatch-icon" class="active-stopwatch item tw-mx-0" href="{{.ActiveStopwatch.IssueLink}}" title="{{ctx.Locale.Tr "active_stopwatch"}}" data-seconds="{{.ActiveStopwatch.Seconds}}">
<div class="tw-relative">
{{svg "octicon-stopwatch"}}
<span class="header-stopwatch-dot"></span>
</div>
</a>
{{end}}
{{if .IsSigned}}
<a id="mobile-notifications-icon" class="item tw-w-auto tw-p-2" href="{{AppSubUrl}}/notifications" data-tooltip-content="{{ctx.Locale.Tr "notifications"}}" aria-label="{{ctx.Locale.Tr "notifications"}}">
<div class="tw-relative">
@ -74,41 +82,13 @@
</div><!-- end content avatar menu -->
</div><!-- end dropdown avatar menu -->
{{else if .IsSigned}}
{{if EnableTimetracking}}
<a class="active-stopwatch-trigger item tw-mx-0{{if not .ActiveStopwatch}} tw-hidden{{end}}" href="{{.ActiveStopwatch.IssueLink}}" title="{{ctx.Locale.Tr "active_stopwatch"}}">
{{if and EnableTimetracking .ActiveStopwatch}}
<a class="item not-mobile active-stopwatch tw-mx-0" href="{{.ActiveStopwatch.IssueLink}}" title="{{ctx.Locale.Tr "active_stopwatch"}}" data-seconds="{{.ActiveStopwatch.Seconds}}">
<div class="tw-relative">
{{svg "octicon-stopwatch"}}
<span class="header-stopwatch-dot"></span>
</div>
<span class="only-mobile tw-ml-2">{{ctx.Locale.Tr "active_stopwatch"}}</span>
</a>
<div class="active-stopwatch-popup item tippy-target tw-p-2">
<div class="tw-flex tw-items-center">
<a class="stopwatch-link tw-flex tw-items-center" href="{{.ActiveStopwatch.IssueLink}}">
{{svg "octicon-issue-opened" 16 "tw-mr-2"}}
<span class="stopwatch-issue">{{.ActiveStopwatch.RepoSlug}}#{{.ActiveStopwatch.IssueIndex}}</span>
<span class="ui primary label stopwatch-time tw-my-0 tw-mx-4" data-seconds="{{.ActiveStopwatch.Seconds}}">
{{if .ActiveStopwatch}}{{Sec2Time .ActiveStopwatch.Seconds}}{{end}}
</span>
</a>
<form class="stopwatch-commit" method="post" action="{{.ActiveStopwatch.IssueLink}}/times/stopwatch/toggle">
{{.CsrfTokenHtml}}
<button
type="submit"
class="ui button mini compact basic icon"
data-tooltip-content="{{ctx.Locale.Tr "repo.issues.stop_tracking"}}"
>{{svg "octicon-square-fill"}}</button>
</form>
<form class="stopwatch-cancel" method="post" action="{{.ActiveStopwatch.IssueLink}}/times/stopwatch/cancel">
{{.CsrfTokenHtml}}
<button
type="submit"
class="ui button mini compact basic icon"
data-tooltip-content="{{ctx.Locale.Tr "repo.issues.cancel_tracking"}}"
>{{svg "octicon-trash"}}</button>
</form>
</div>
</div>
{{end}}
<a class="item not-mobile tw-mx-0" href="{{AppSubUrl}}/notifications" data-tooltip-content="{{ctx.Locale.Tr "notifications"}}" aria-label="{{ctx.Locale.Tr "notifications"}}">
@ -202,4 +182,33 @@
</a>
{{end}}
</div><!-- end full right menu -->
{{if and .IsSigned EnableTimetracking .ActiveStopwatch}}
<div class="active-stopwatch-popup tippy-target">
<div class="tw-flex tw-items-center tw-gap-2 tw-p-3">
<a class="stopwatch-link tw-flex tw-items-center tw-gap-2 muted" href="{{.ActiveStopwatch.IssueLink}}">
{{svg "octicon-issue-opened" 16}}
<span class="stopwatch-issue">{{.ActiveStopwatch.RepoSlug}}#{{.ActiveStopwatch.IssueIndex}}</span>
</a>
<div class="tw-flex tw-gap-1">
<form class="stopwatch-commit" method="post" action="{{.ActiveStopwatch.IssueLink}}/times/stopwatch/toggle">
{{.CsrfTokenHtml}}
<button
type="submit"
class="ui button mini compact basic icon tw-mr-0"
data-tooltip-content="{{ctx.Locale.Tr "repo.issues.stop_tracking"}}"
>{{svg "octicon-square-fill"}}</button>
</form>
<form class="stopwatch-cancel" method="post" action="{{.ActiveStopwatch.IssueLink}}/times/stopwatch/cancel">
{{.CsrfTokenHtml}}
<button
type="submit"
class="ui button mini compact basic icon tw-mr-0"
data-tooltip-content="{{ctx.Locale.Tr "repo.issues.cancel_tracking"}}"
>{{svg "octicon-trash"}}</button>
</form>
</div>
</div>
</div>
{{end}}
</nav>

View File

@ -40,8 +40,9 @@
</a>
{{end}}
{{if .IsOrganizationOwner}}
<span class="item-flex-space"></span>
<a class="{{if .PageIsOrgSettings}}active {{end}}item" href="{{.OrgLink}}/settings">
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
</a>
{{end}}
</div>

View File

@ -216,6 +216,7 @@
{{template "custom/extra_tabs" .}}
{{if .Permission.IsAdmin}}
<span class="item-flex-space"></span>
<a class="{{if .PageIsRepoSettings}}active {{end}} item" href="{{.RepoLink}}/settings">
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
</a>

View File

@ -684,7 +684,9 @@ func TestAPIRepoGetReviewers(t *testing.T) {
resp := MakeRequest(t, req, http.StatusOK)
var reviewers []*api.User
DecodeJSON(t, resp, &reviewers)
assert.Len(t, reviewers, 4)
if assert.Len(t, reviewers, 3) {
assert.ElementsMatch(t, []int64{1, 4, 11}, []int64{reviewers[0].ID, reviewers[1].ID, reviewers[2].ID})
}
}
func TestAPIRepoGetAssignees(t *testing.T) {

View File

@ -6,6 +6,7 @@ package integration
import (
"context"
"fmt"
"html/template"
"net/http"
"net/url"
"path"
@ -573,10 +574,14 @@ func TestGetIssueInfo(t *testing.T) {
urlStr := fmt.Sprintf("/%s/%s/issues/%d/info", owner.Name, repo.Name, issue.Index)
req := NewRequest(t, "GET", urlStr)
resp := session.MakeRequest(t, req, http.StatusOK)
var apiIssue api.Issue
DecodeJSON(t, resp, &apiIssue)
var respStruct struct {
ConvertedIssue api.Issue
RenderedLabels template.HTML
}
DecodeJSON(t, resp, &respStruct)
assert.EqualValues(t, issue.ID, apiIssue.ID)
assert.EqualValues(t, issue.ID, respStruct.ConvertedIssue.ID)
assert.Contains(t, string(respStruct.RenderedLabels), `"labels-list"`)
}
func TestUpdateIssueDeadline(t *testing.T) {

View File

@ -942,6 +942,23 @@ overflow-menu .overflow-menu-items .item {
margin-bottom: 0 !important; /* reset fomantic's margin, because the active menu has special bottom border */
}
overflow-menu .overflow-menu-items .item-flex-space {
flex: 1;
}
overflow-menu .overflow-menu-button {
background: transparent;
border: none;
color: inherit;
text-align: center;
width: 32px;
padding: 0;
}
overflow-menu .overflow-menu-button:hover {
color: var(--color-text-dark);
}
overflow-menu .ui.label {
margin-left: 7px !important; /* save some space */
}

View File

@ -6,38 +6,6 @@
max-width: 100%;
}
@media (max-width: 767.98px) {
.ui.ui.ui.container:not(.fluid) {
width: auto;
margin-left: 1em;
margin-right: 1em;
}
}
@media (min-width: 768px) and (max-width: 991.98px) {
.ui.ui.ui.container:not(.fluid) {
width: 723px;
margin-left: auto;
margin-right: auto;
}
}
@media (min-width: 992px) and (max-width: 1199.98px) {
.ui.ui.ui.container:not(.fluid) {
width: 933px;
margin-left: auto;
margin-right: auto;
}
}
@media (min-width: 1200px) {
.ui.ui.ui.container:not(.fluid) {
width: 1127px;
margin-left: auto;
margin-right: auto;
}
}
.ui.fluid.container {
width: 100%;
}

View File

@ -103,19 +103,12 @@
width: 50%;
min-height: 48px;
}
#navbar #mobile-stopwatch-icon,
#navbar #mobile-notifications-icon {
margin-right: 6px !important;
}
}
#navbar a.item .notification_count {
color: var(--color-nav-bg);
padding: 0 3.75px;
font-size: 12px;
line-height: 12px;
font-weight: var(--font-weight-bold);
}
#navbar a.item:hover .notification_count,
#navbar a.item:hover .header-stopwatch-dot {
border-color: var(--color-nav-hover-bg);
@ -123,6 +116,11 @@
#navbar a.item .notification_count,
#navbar a.item .header-stopwatch-dot {
color: var(--color-nav-bg);
padding: 0 3.75px;
font-size: 12px;
line-height: 12px;
font-weight: var(--font-weight-bold);
background: var(--color-primary);
border: 2px solid var(--color-nav-bg);
position: absolute;
@ -135,6 +133,8 @@
align-items: center;
justify-content: center;
z-index: 1; /* prevent menu button background from overlaying icon */
user-select: none;
white-space: nowrap;
}
.secondary-nav {

View File

@ -16,8 +16,8 @@
.tippy-box {
position: relative;
background-color: var(--color-body);
color: var(--color-secondary-dark-6);
background-color: var(--color-menu);
color: var(--color-text);
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius);
font-size: 1rem;
@ -25,7 +25,6 @@
.tippy-content {
position: relative;
padding: 1rem; /* if you need different padding, use different data-theme */
z-index: 1;
}
@ -166,5 +165,5 @@
}
.tippy-svg-arrow-inner {
fill: var(--color-body);
fill: var(--color-menu);
}

View File

@ -1,6 +1,5 @@
<script>
import {SvgIcon} from '../svg.js';
import {contrastColor} from '../utils/color.js';
import {GET} from '../modules/fetch.js';
const {appSubUrl, i18n} = window.config;
@ -10,6 +9,7 @@ export default {
data: () => ({
loading: false,
issue: null,
renderedLabels: '',
i18nErrorOccurred: i18n.error_occurred,
i18nErrorMessage: null,
}),
@ -56,14 +56,6 @@ export default {
}
return 'red'; // Closed Issue
},
labels() {
return this.issue.labels.map((label) => ({
name: label.name,
color: `#${label.color}`,
textColor: contrastColor(`#${label.color}`),
}));
},
},
mounted() {
this.$refs.root.addEventListener('ce-load-context-popup', (e) => {
@ -79,13 +71,14 @@ export default {
this.i18nErrorMessage = null;
try {
const response = await GET(`${appSubUrl}/${data.owner}/${data.repo}/issues/${data.index}/info`);
const response = await GET(`${appSubUrl}/${data.owner}/${data.repo}/issues/${data.index}/info`); // backend: GetIssueInfo
const respJson = await response.json();
if (!response.ok) {
this.i18nErrorMessage = respJson.message ?? i18n.network_error;
return;
}
this.issue = respJson;
this.issue = respJson.convertedIssue;
this.renderedLabels = respJson.renderedLabels;
} catch {
this.i18nErrorMessage = i18n.network_error;
} finally {
@ -102,16 +95,8 @@ export default {
<p><small>{{ issue.repository.full_name }} on {{ createdAt }}</small></p>
<p><svg-icon :name="icon" :class="['text', color]"/> <strong>{{ issue.title }}</strong> #{{ issue.number }}</p>
<p>{{ body }}</p>
<div class="labels-list">
<div
v-for="label in labels"
:key="label.name"
class="ui label"
:style="{ color: label.textColor, backgroundColor: label.color }"
>
{{ label.name }}
</div>
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="renderedLabels"/>
</div>
<div v-if="!loading && issue === null">
<p><small>{{ i18nErrorOccurred }}</small></p>

View File

@ -251,9 +251,9 @@ const sfc = {
this.repos = json.data.map((webSearchRepo) => {
return {
...webSearchRepo.repository,
latest_commit_status_state: webSearchRepo.latest_commit_status.State,
latest_commit_status_state: webSearchRepo.latest_commit_status?.State, // if latest_commit_status is null, it means there is no commit status
latest_commit_status_state_link: webSearchRepo.latest_commit_status?.TargetURL,
locale_latest_commit_status_state: webSearchRepo.locale_latest_commit_status,
latest_commit_status_state_link: webSearchRepo.latest_commit_status.TargetURL,
};
});
const count = response.headers.get('X-Total-Count');

View File

@ -53,7 +53,7 @@ export function initCommonIssueListQuickGoto() {
// try to check whether the parsed goto link is valid
let targetUrl = parseIssueListQuickGotoLink(repoLink, searchText);
if (targetUrl) {
const res = await GET(`${targetUrl}/info`);
const res = await GET(`${targetUrl}/info`); // backend: GetIssueInfo, it only checks whether the issue exists by status code
if (res.status !== 200) targetUrl = '';
}
// if the input value has changed, then ignore the result

View File

@ -18,6 +18,7 @@ export function attachRefIssueContextPopup(refIssues) {
if (!owner) return;
const el = document.createElement('div');
el.classList.add('tw-p-3');
refIssue.parentNode.insertBefore(el, refIssue.nextSibling);
const view = createApp(ContextPopup);
@ -30,6 +31,7 @@ export function attachRefIssueContextPopup(refIssues) {
}
createTippy(refIssue, {
theme: 'default',
content: el,
placement: 'top-start',
interactive: true,

View File

@ -1,6 +1,7 @@
import $ from 'jquery';
import {GET} from '../modules/fetch.js';
import {toggleElem} from '../utils/dom.js';
import {logoutFromWorker} from '../modules/worker.js';
const {appSubUrl, notificationSettings, assetVersionEncoded} = window.config;
let notificationSequenceNumber = 0;
@ -95,7 +96,7 @@ export function initNotificationCount() {
type: 'close',
});
worker.port.close();
window.location.href = `${appSubUrl}/`;
logoutFromWorker();
} else if (event.data.type === 'close') {
worker.port.postMessage({
type: 'close',

View File

@ -113,6 +113,7 @@ function showLineButton() {
btn.closest('.code-view').append(menu.cloneNode(true));
createTippy(btn, {
theme: 'menu',
trigger: 'click',
hideOnClick: true,
content: menu,

View File

@ -502,6 +502,7 @@ export function initRepoPullRequestReview() {
if ($reviewBtn.length && $panel.length) {
const tippy = createTippy($reviewBtn[0], {
content: $panel[0],
theme: 'default',
placement: 'bottom',
trigger: 'click',
maxWidth: 'none',

View File

@ -1,7 +1,7 @@
import prettyMilliseconds from 'pretty-ms';
import {createTippy} from '../modules/tippy.js';
import {GET} from '../modules/fetch.js';
import {hideElem, showElem} from '../utils/dom.js';
import {logoutFromWorker} from '../modules/worker.js';
const {appSubUrl, notificationSettings, enableTimeTracking, assetVersionEncoded} = window.config;
@ -10,28 +10,31 @@ export function initStopwatch() {
return;
}
const stopwatchEl = document.querySelector('.active-stopwatch-trigger');
const stopwatchEls = document.querySelectorAll('.active-stopwatch');
const stopwatchPopup = document.querySelector('.active-stopwatch-popup');
if (!stopwatchEl || !stopwatchPopup) {
if (!stopwatchEls.length || !stopwatchPopup) {
return;
}
stopwatchEl.removeAttribute('href'); // intended for noscript mode only
createTippy(stopwatchEl, {
content: stopwatchPopup,
placement: 'bottom-end',
trigger: 'click',
maxWidth: 'none',
interactive: true,
hideOnClick: true,
});
// global stop watch (in the head_navbar), it should always work in any case either the EventSource or the PeriodicPoller is used.
const currSeconds = document.querySelector('.stopwatch-time')?.getAttribute('data-seconds');
if (currSeconds) {
updateStopwatchTime(currSeconds);
const seconds = stopwatchEls[0]?.getAttribute('data-seconds');
if (seconds) {
updateStopwatchTime(parseInt(seconds));
}
for (const stopwatchEl of stopwatchEls) {
stopwatchEl.removeAttribute('href'); // intended for noscript mode only
createTippy(stopwatchEl, {
content: stopwatchPopup.cloneNode(true),
placement: 'bottom-end',
trigger: 'click',
maxWidth: 'none',
interactive: true,
hideOnClick: true,
theme: 'default',
});
}
let usingPeriodicPoller = false;
@ -75,7 +78,7 @@ export function initStopwatch() {
type: 'close',
});
worker.port.close();
window.location.href = `${appSubUrl}/`;
logoutFromWorker();
} else if (event.data.type === 'close') {
worker.port.postMessage({
type: 'close',
@ -124,10 +127,9 @@ async function updateStopwatch() {
function updateStopwatchData(data) {
const watch = data[0];
const btnEl = document.querySelector('.active-stopwatch-trigger');
const btnEls = document.querySelectorAll('.active-stopwatch');
if (!watch) {
clearStopwatchTimer();
hideElem(btnEl);
hideElem(btnEls);
} else {
const {repo_owner_name, repo_name, issue_index, seconds} = watch;
const issueUrl = `${appSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`;
@ -137,31 +139,28 @@ function updateStopwatchData(data) {
const stopwatchIssue = document.querySelector('.stopwatch-issue');
if (stopwatchIssue) stopwatchIssue.textContent = `${repo_owner_name}/${repo_name}#${issue_index}`;
updateStopwatchTime(seconds);
showElem(btnEl);
showElem(btnEls);
}
return Boolean(data.length);
}
let updateTimeIntervalId = null; // holds setInterval id when active
function clearStopwatchTimer() {
if (updateTimeIntervalId !== null) {
clearInterval(updateTimeIntervalId);
updateTimeIntervalId = null;
// TODO: This flickers on page load, we could avoid this by making a custom
// element to render time periods. Feeding a datetime in backend does not work
// when time zone between server and client differs.
function updateStopwatchTime(seconds) {
if (!Number.isFinite(seconds)) return;
const datetime = (new Date(Date.now() - seconds * 1000)).toISOString();
for (const parent of document.querySelectorAll('.header-stopwatch-dot')) {
const existing = parent.querySelector(':scope > relative-time');
if (existing) {
existing.setAttribute('datetime', datetime);
} else {
const el = document.createElement('relative-time');
el.setAttribute('format', 'micro');
el.setAttribute('datetime', datetime);
el.setAttribute('lang', 'en-US');
el.setAttribute('title', ''); // make <relative-time> show no title and therefor no tooltip
parent.append(el);
}
}
}
function updateStopwatchTime(seconds) {
const secs = parseInt(seconds);
if (!Number.isFinite(secs)) return;
clearStopwatchTimer();
const stopwatch = document.querySelector('.stopwatch-time');
// TODO: replace with <relative-time> similar to how system status up time is shown
const start = Date.now();
const updateUi = () => {
const delta = Date.now() - start;
const dur = prettyMilliseconds(secs * 1000 + delta, {compact: true});
if (stopwatch) stopwatch.textContent = dur;
};
updateUi();
updateTimeIntervalId = setInterval(updateUi, 1000);
}

View File

@ -37,8 +37,10 @@ export function createTippy(target, opts = {}) {
return onShow?.(instance);
},
arrow: arrow || (theme === 'bare' ? false : arrowSvg),
role: role || 'menu', // HTML role attribute
theme: theme || role || 'menu', // CSS theme, either "tooltip", "menu", "box-with-header" or "bare"
// HTML role attribute, ideally the default role would be "popover" but it does not exist
role: role || 'menu',
// CSS theme, either "default", "tooltip", "menu", "box-with-header" or "bare"
theme: theme || role || 'default',
plugins: [followCursor],
...other,
});

View File

@ -0,0 +1,9 @@
import {sleep} from '../utils.js';
const {appSubUrl} = window.config;
export async function logoutFromWorker() {
// wait for a while because other requests (eg: logout) may be in the flight
await sleep(5000);
window.location.href = `${appSubUrl}/`;
}

View File

@ -8,7 +8,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
if (!this.tippyContent) {
const div = document.createElement('div');
div.classList.add('tippy-target');
div.tabIndex = '-1'; // for initial focus, programmatic focus only
div.tabIndex = -1; // for initial focus, programmatic focus only
div.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
const items = this.tippyContent.querySelectorAll('[role="menuitem"]');
@ -60,21 +60,35 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
this.tippyContent = div;
}
const itemFlexSpace = this.menuItemsEl.querySelector('.item-flex-space');
// move items in tippy back into the menu items for subsequent measurement
for (const item of this.tippyItems || []) {
this.menuItemsEl.append(item);
if (!itemFlexSpace || item.getAttribute('data-after-flex-space')) {
this.menuItemsEl.append(item);
} else {
itemFlexSpace.insertAdjacentElement('beforebegin', item);
}
}
// measure which items are partially outside the element and move them into the button menu
itemFlexSpace?.style.setProperty('display', 'none', 'important');
this.tippyItems = [];
const menuRight = this.offsetLeft + this.offsetWidth;
const menuItems = this.menuItemsEl.querySelectorAll('.item');
const menuItems = this.menuItemsEl.querySelectorAll('.item, .item-flex-space');
let afterFlexSpace = false;
for (const item of menuItems) {
if (item.classList.contains('item-flex-space')) {
afterFlexSpace = true;
continue;
}
if (afterFlexSpace) item.setAttribute('data-after-flex-space', 'true');
const itemRight = item.offsetLeft + item.offsetWidth;
if (menuRight - itemRight < 38) { // roughly the width of .overflow-menu-button
if (menuRight - itemRight < 38) { // roughly the width of .overflow-menu-button with some extra space
this.tippyItems.push(item);
}
}
itemFlexSpace?.style.removeProperty('display');
// if there are no overflown items, remove any previously created button
if (!this.tippyItems?.length) {
@ -105,7 +119,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
// create button initially
const btn = document.createElement('button');
btn.classList.add('overflow-menu-button', 'btn', 'tw-px-2', 'hover:tw-text-text-dark');
btn.classList.add('overflow-menu-button');
btn.setAttribute('aria-label', window.config.i18n.more_items);
btn.innerHTML = octiconKebabHorizontal;
this.append(btn);
@ -117,6 +131,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
interactive: true,
placement: 'bottom-end',
role: 'menu',
theme: 'menu',
content: this.tippyContent,
onShow: () => { // FIXME: onShown doesn't work (never be called)
setTimeout(() => {