Compare commits
No commits in common. "fabd6123f89c852b977ad542af2893d4b9fb7cc6" and "681e10c7dbc99a5cfd2680904aaefabd9ac53268" have entirely different histories.
fabd6123f8
...
681e10c7db
|
|
@ -409,7 +409,6 @@
|
||||||
loadMilestones: item => issueController.loadMilestones(item),
|
loadMilestones: item => issueController.loadMilestones(item),
|
||||||
});
|
});
|
||||||
let issueCapture = null;
|
let issueCapture = null;
|
||||||
let updateFollowUp = null;
|
|
||||||
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
|
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
|
||||||
const unfiledCaptures = createUnfiledCaptures({
|
const unfiledCaptures = createUnfiledCaptures({
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
|
|
@ -499,7 +498,6 @@
|
||||||
}, () => {
|
}, () => {
|
||||||
if (!issueCapture) {
|
if (!issueCapture) {
|
||||||
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||||
updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
|
|
||||||
}
|
}
|
||||||
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
|
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
|
||||||
sharedLaunchState = issueCapture.stageSharedContent(sharedLaunch);
|
sharedLaunchState = issueCapture.stageSharedContent(sharedLaunch);
|
||||||
|
|
@ -3350,7 +3348,6 @@
|
||||||
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
|
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
|
||||||
scheduleIssueDuplicateCheck();
|
scheduleIssueDuplicateCheck();
|
||||||
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
|
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
|
||||||
qs('#create-follow-up-next').hidden = !updateFollowUp?.source();
|
|
||||||
updateIssueCreateActions();
|
updateIssueCreateActions();
|
||||||
qs('#create-issue-sheet').classList.add('open');
|
qs('#create-issue-sheet').classList.add('open');
|
||||||
creatingIssue = true;
|
creatingIssue = true;
|
||||||
|
|
@ -4181,11 +4178,11 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
|
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
|
||||||
|
const updateFollowUp = createUpdateFollowUp();
|
||||||
qs('#create-update-follow-up').addEventListener('click', async () => {
|
qs('#create-update-follow-up').addEventListener('click', async () => {
|
||||||
if (!selectedUpdate || !selectedUpdateDetail || !await ensureIssueCapture()) return;
|
if (!selectedUpdate || !selectedUpdateDetail || !await ensureIssueCapture()) return;
|
||||||
const source = {item: selectedUpdate, detail: selectedUpdateDetail};
|
const source = {item: selectedUpdate, detail: selectedUpdateDetail};
|
||||||
const state = issueCapture.stageFollowUp(updateFollowUp.draft(selectedUpdateDetail));
|
const state = issueCapture.stageFollowUp(updateFollowUp.draft(selectedUpdateDetail));
|
||||||
updateFollowUp.stageSource(source.item);
|
|
||||||
followUpSourceUpdate = source;
|
followUpSourceUpdate = source;
|
||||||
closeUpdateSheet(false, false);
|
closeUpdateSheet(false, false);
|
||||||
await openCreateIssueSheet(false);
|
await openCreateIssueSheet(false);
|
||||||
|
|
@ -4196,7 +4193,6 @@
|
||||||
} else {
|
} else {
|
||||||
qs('#create-issue-status').textContent = 'Follow-up context added. Review, save to Drafts, or create it.';
|
qs('#create-issue-status').textContent = 'Follow-up context added. Review, save to Drafts, or create it.';
|
||||||
}
|
}
|
||||||
qs('#create-follow-up-next').hidden = false;
|
|
||||||
});
|
});
|
||||||
dFS.bind();
|
dFS.bind();
|
||||||
qs('#file-new-issue').addEventListener('click', () => {
|
qs('#file-new-issue').addEventListener('click', () => {
|
||||||
|
|
@ -4255,7 +4251,6 @@
|
||||||
qs('#resume-issue-draft').addEventListener('click', () => {
|
qs('#resume-issue-draft').addEventListener('click', () => {
|
||||||
issueCapture.discardSharedContent();
|
issueCapture.discardSharedContent();
|
||||||
issueCapture.discardFollowUp();
|
issueCapture.discardFollowUp();
|
||||||
updateFollowUp.discardSource();
|
|
||||||
qs('#shared-content-conflict').hidden = true;
|
qs('#shared-content-conflict').hidden = true;
|
||||||
sharedLaunchState = null;
|
sharedLaunchState = null;
|
||||||
clearSharedLaunchUrl();
|
clearSharedLaunchUrl();
|
||||||
|
|
@ -4339,7 +4334,6 @@
|
||||||
qs('#create-issue-form').addEventListener('submit', async event => {
|
qs('#create-issue-form').addEventListener('submit', async event => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (event.submitter) createAndStartRequested = event.submitter?.id === 'create-and-start-issue';
|
if (event.submitter) createAndStartRequested = event.submitter?.id === 'create-and-start-issue';
|
||||||
const followUpNextRequested = event.submitter?.id === 'create-follow-up-next';
|
|
||||||
const captureDraft = currentIssueCaptureDraft();
|
const captureDraft = currentIssueCaptureDraft();
|
||||||
if (!captureDraft.repository || !captureDraft.title) {
|
if (!captureDraft.repository || !captureDraft.title) {
|
||||||
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
|
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
|
||||||
|
|
@ -4359,10 +4353,8 @@
|
||||||
}
|
}
|
||||||
const button = qs('#submit-new-issue');
|
const button = qs('#submit-new-issue');
|
||||||
const startButton = qs('#create-and-start-issue');
|
const startButton = qs('#create-and-start-issue');
|
||||||
const followUpButton = qs('#create-follow-up-next');
|
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
startButton.disabled = true;
|
startButton.disabled = true;
|
||||||
followUpButton.disabled = true;
|
|
||||||
qs('#create-issue-status').textContent = createAndStartRequested ?
|
qs('#create-issue-status').textContent = createAndStartRequested ?
|
||||||
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
|
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
|
||||||
try {
|
try {
|
||||||
|
|
@ -4372,13 +4364,8 @@
|
||||||
...(rUC ? { sourceCaptureId: rUC } : {}),
|
...(rUC ? { sourceCaptureId: rUC } : {}),
|
||||||
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
|
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
|
||||||
};
|
};
|
||||||
const admission = followUpNextRequested ? (await updateFollowUp.complete({
|
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
||||||
admit: () => editingOutboxId ? issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
await issueOutbox.enqueueDurably(durableDraft);
|
||||||
issueOutbox.enqueueDurably(durableDraft),
|
|
||||||
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
|
|
||||||
advance: source => notificationReader.acceptReadAndNext(lastMyWork, source),
|
|
||||||
})).admission : (editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
|
||||||
await issueOutbox.enqueueDurably(durableDraft));
|
|
||||||
const queued = admission.item;
|
const queued = admission.item;
|
||||||
const fS = dFS.current();
|
const fS = dFS.current();
|
||||||
if (rUC && (!durableDraft.attachment || admission.background)) {
|
if (rUC && (!durableDraft.attachment || admission.background)) {
|
||||||
|
|
@ -4398,8 +4385,6 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
editingOutboxId = null;
|
editingOutboxId = null;
|
||||||
followUpSourceUpdate = null;
|
|
||||||
if (!followUpNextRequested) updateFollowUp.discardSource();
|
|
||||||
issueCapture.clearDraft();
|
issueCapture.clearDraft();
|
||||||
createIssueAttachmentController.clear();
|
createIssueAttachmentController.clear();
|
||||||
suppressCreateDraftOnHistoryClose = true;
|
suppressCreateDraftOnHistoryClose = true;
|
||||||
|
|
@ -4414,7 +4399,6 @@
|
||||||
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
|
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
startButton.disabled = !createAndStart.available();
|
startButton.disabled = !createAndStart.available();
|
||||||
followUpButton.disabled = false;
|
|
||||||
qs('#create-issue-title').focus();
|
qs('#create-issue-title').focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -609,7 +609,6 @@
|
||||||
<div class="small">The issue will be assigned to you.</div>
|
<div class="small">The issue will be assigned to you.</div>
|
||||||
<div class="create-issue-actions">
|
<div class="create-issue-actions">
|
||||||
<button id="submit-new-issue" type="submit">Create & assign to me</button>
|
<button id="submit-new-issue" type="submit">Create & assign to me</button>
|
||||||
<button id="create-follow-up-next" type="submit" hidden>Create follow-up & next</button>
|
|
||||||
<button id="create-and-start-issue" type="submit">Create & start</button>
|
<button id="create-and-start-issue" type="submit">Create & start</button>
|
||||||
<div id="create-issue-status" class="small" aria-live="assertive"></div>
|
<div id="create-issue-status" class="small" aria-live="assertive"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -404,8 +404,7 @@ function createNotificationReader({
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
acceptReadAndNext(items, item = selected) {
|
acceptReadAndNext(items, item = selected) {
|
||||||
if (!item || (selected && selected !== item)) return false;
|
if (!item || selected !== item) return false;
|
||||||
selected = item;
|
|
||||||
return advanceAfterRead(items, item);
|
return advanceAfterRead(items, item);
|
||||||
},
|
},
|
||||||
async loadOlder() {
|
async loadOlder() {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
function createUpdateFollowUp({ storage = null, getLogin = () => '' } = {}) {
|
function createUpdateFollowUp() {
|
||||||
const sourceKey = 'stackchain.update-follow-up-source.v1';
|
|
||||||
const clean = (value, limit) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, limit);
|
const clean = (value, limit) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, limit);
|
||||||
const safeRepository = value => {
|
const safeRepository = value => {
|
||||||
const candidate = String(value || '');
|
const candidate = String(value || '');
|
||||||
|
|
@ -27,61 +26,7 @@ function createUpdateFollowUp({ storage = null, getLogin = () => '' } = {}) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function readSource() {
|
return { draft };
|
||||||
const login = String(getLogin() || '').trim();
|
|
||||||
if (!login) return null;
|
|
||||||
try {
|
|
||||||
const record = JSON.parse(storage?.getItem(sourceKey) || 'null');
|
|
||||||
return record?.version === 1 && record.ownerLogin === login &&
|
|
||||||
Number.isInteger(record.source?.notification_id) && record.source.notification_id > 0 ? record : null;
|
|
||||||
} catch (_error) { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeSource(record) {
|
|
||||||
storage?.setItem(sourceKey, JSON.stringify(record));
|
|
||||||
}
|
|
||||||
|
|
||||||
function stageSource(item = {}) {
|
|
||||||
const ownerLogin = String(getLogin() || '').trim();
|
|
||||||
const notificationId = Number(item.notification_id);
|
|
||||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before continuing this follow-up.');
|
|
||||||
if (!Number.isInteger(notificationId) || notificationId <= 0) throw new Error('Choose a valid source update.');
|
|
||||||
const source = {
|
|
||||||
notification_id: notificationId,
|
|
||||||
repository: safeRepository(item.repository),
|
|
||||||
number: Number.isInteger(item.number) && item.number > 0 ? item.number : null,
|
|
||||||
title: clean(item.title, 240), kind: 'update', has_update: true,
|
|
||||||
};
|
|
||||||
writeSource({ version:1, ownerLogin, source, admission:null });
|
|
||||||
return { ...source };
|
|
||||||
}
|
|
||||||
|
|
||||||
function source() {
|
|
||||||
const record = readSource();
|
|
||||||
return record ? { ...record.source } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function discardSource() {
|
|
||||||
if (!readSource()) return false;
|
|
||||||
storage?.removeItem(sourceKey);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function complete({ admit, queueRead, advance }) {
|
|
||||||
const record = readSource();
|
|
||||||
if (!record) throw new Error('Reopen the source update before continuing.');
|
|
||||||
let admission = record.admission;
|
|
||||||
if (!admission) {
|
|
||||||
admission = await admit();
|
|
||||||
writeSource({ ...record, admission });
|
|
||||||
}
|
|
||||||
await queueRead(record.source.notification_id);
|
|
||||||
const advanced = await advance({ ...record.source });
|
|
||||||
storage?.removeItem(sourceKey);
|
|
||||||
return { admission, advanced:Boolean(advanced) };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { draft, stageSource, source, discardSource, complete };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateFollowUp;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateFollowUp;
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ COMMONJS_BROWSER_BRANCH = re.compile(
|
||||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||||
FEATURE_SOURCES = {
|
FEATURE_SOURCES = {
|
||||||
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
|
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
|
||||||
"issue-capture": ("static/create-issue-sheet.js", "static/update-follow-up.js"),
|
"issue-capture": ("static/create-issue-sheet.js",),
|
||||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||||
"push-notifications": ("static/push-notifications.js",),
|
"push-notifications": ("static/push-notifications.js",),
|
||||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||||
|
|
|
||||||
|
|
@ -58,87 +58,6 @@ process.stdout.write(JSON.stringify(controller.draft({{
|
||||||
assert len(output["body"]) <= 9500
|
assert len(output["body"]) <= 9500
|
||||||
|
|
||||||
|
|
||||||
def test_follow_up_continuation_is_account_bound_and_survives_reload():
|
|
||||||
script = f"""
|
|
||||||
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
|
|
||||||
const values = new Map();
|
|
||||||
const storage = {{
|
|
||||||
getItem:key => values.has(key) ? values.get(key) : null,
|
|
||||||
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key)
|
|
||||||
}};
|
|
||||||
let login = 'timmy';
|
|
||||||
const first = createFollowUp({{storage, getLogin:()=>login}});
|
|
||||||
const staged = first.stageSource({{
|
|
||||||
notification_id: 42, repository:'stackchain/stackchain-dashboard', number:665,
|
|
||||||
title:'Unread follow-up source', kind:'update', has_update:true
|
|
||||||
}});
|
|
||||||
const restored = createFollowUp({{storage, getLogin:()=>login}}).source();
|
|
||||||
login = 'alexander';
|
|
||||||
const isolated = createFollowUp({{storage, getLogin:()=>login}}).source();
|
|
||||||
process.stdout.write(JSON.stringify({{staged, restored, isolated}}));
|
|
||||||
"""
|
|
||||||
output = run_node(script)
|
|
||||||
assert output["staged"]["notification_id"] == 42
|
|
||||||
assert output["restored"] == output["staged"]
|
|
||||||
assert output["isolated"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_follow_up_completion_admits_issue_before_durable_read_and_advances_once():
|
|
||||||
script = f"""
|
|
||||||
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
|
|
||||||
const values = new Map();
|
|
||||||
const storage = {{
|
|
||||||
getItem:key => values.has(key) ? values.get(key) : null,
|
|
||||||
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key)
|
|
||||||
}};
|
|
||||||
const order = [];
|
|
||||||
const controller = createFollowUp({{storage, getLogin:()=> 'timmy'}});
|
|
||||||
controller.stageSource({{notification_id:42, repository:'o/r', number:7, title:'Source'}});
|
|
||||||
(async () => {{
|
|
||||||
const result = await controller.complete({{
|
|
||||||
admit:async()=>{{ order.push('issue'); return {{item:{{id:'issue:1'}}}}; }},
|
|
||||||
queueRead:async id=>{{ order.push('read:' + id); return {{item:{{id:'read:42'}}}}; }},
|
|
||||||
advance:async source=>{{ order.push('next:' + source.notification_id); return true; }},
|
|
||||||
}});
|
|
||||||
process.stdout.write(JSON.stringify({{order, result, pending:controller.source()}}));
|
|
||||||
}})();
|
|
||||||
"""
|
|
||||||
output = run_node(script)
|
|
||||||
assert output == {
|
|
||||||
"order": ["issue", "read:42", "next:42"],
|
|
||||||
"result": {"admission": {"item": {"id": "issue:1"}}, "advanced": True},
|
|
||||||
"pending": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_follow_up_completion_preserves_source_when_issue_admission_fails():
|
|
||||||
script = f"""
|
|
||||||
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
|
|
||||||
const values = new Map();
|
|
||||||
const storage = {{
|
|
||||||
getItem:key => values.has(key) ? values.get(key) : null,
|
|
||||||
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key)
|
|
||||||
}};
|
|
||||||
const order = [];
|
|
||||||
const controller = createFollowUp({{storage, getLogin:()=> 'timmy'}});
|
|
||||||
controller.stageSource({{notification_id:42, repository:'o/r', number:7, title:'Source'}});
|
|
||||||
(async () => {{
|
|
||||||
try {{
|
|
||||||
await controller.complete({{
|
|
||||||
admit:async()=>{{ order.push('issue'); throw new Error('disk full'); }},
|
|
||||||
queueRead:async()=>order.push('read'), advance:async()=>order.push('next'),
|
|
||||||
}});
|
|
||||||
}} catch (error) {{
|
|
||||||
process.stdout.write(JSON.stringify({{order, error:error.message, pending:controller.source()}}));
|
|
||||||
}}
|
|
||||||
}})();
|
|
||||||
"""
|
|
||||||
output = run_node(script)
|
|
||||||
assert output["order"] == ["issue"]
|
|
||||||
assert output["error"] == "disk full"
|
|
||||||
assert output["pending"]["notification_id"] == 42
|
|
||||||
|
|
||||||
|
|
||||||
def test_follow_up_staging_never_silently_overwrites_an_existing_capture():
|
def test_follow_up_staging_never_silently_overwrites_an_existing_capture():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
const createCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||||
|
|
@ -176,23 +95,10 @@ process.stdout.write(JSON.stringify({{state, before, accepted, pending:capture.p
|
||||||
async def test_update_sheet_wires_phone_safe_follow_up_without_marking_read():
|
async def test_update_sheet_wires_phone_safe_follow_up_without_marking_read():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
||||||
assert 'name="stackchain-feature-issue-capture"' in html
|
assert '<script src="static/update-follow-up.js"></script>' in html
|
||||||
assert 'id="create-update-follow-up"' in html
|
assert 'id="create-update-follow-up"' in html
|
||||||
assert '>Create follow-up</button>' in html
|
assert '>Create follow-up</button>' in html
|
||||||
assert "issueCapture.stageFollowUp(updateFollowUp.draft(selectedUpdateDetail))" in html
|
assert "issueCapture.stageFollowUp(updateFollowUp.draft(selectedUpdateDetail))" in html
|
||||||
assert "qs('#create-update-follow-up').addEventListener('click'" in html
|
assert "qs('#create-update-follow-up').addEventListener('click'" in html
|
||||||
assert "markNotificationRead" not in html.split("qs('#create-update-follow-up').addEventListener('click'", 1)[1].split("});", 1)[0]
|
assert "markNotificationRead" not in html.split("qs('#create-update-follow-up').addEventListener('click'", 1)[1].split("});", 1)[0]
|
||||||
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
|
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_follow_up_capture_offers_durable_create_and_next_flow():
|
|
||||||
html = await dashboard()
|
|
||||||
|
|
||||||
assert 'id="create-follow-up-next"' in html
|
|
||||||
assert '>Create follow-up & next</button>' in html
|
|
||||||
assert "updateFollowUp.stageSource(source.item)" in html
|
|
||||||
assert "event.submitter?.id === 'create-follow-up-next'" in html
|
|
||||||
assert "queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId)" in html
|
|
||||||
assert "advance: source => notificationReader.acceptReadAndNext(lastMyWork, source)" in html
|
|
||||||
assert ".create-issue-actions button { min-height:44px;" in html
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user