snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
4
web/.browserslistrc
Normal file
@@ -0,0 +1,4 @@
|
||||
baseline widely available with downstream, unreleased versions
|
||||
|
||||
[test]
|
||||
current Node
|
||||
9
web/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# From emoji
|
||||
/generated/emoji
|
||||
/generated/emoji-styles
|
||||
# From passing pygments data to the frontend
|
||||
/generated/pygments_data.json
|
||||
# From passing time zone data to the frontend
|
||||
/generated/timezones.json
|
||||
# From browserslist
|
||||
/generated/supported_browser_regex.ts
|
||||
38
web/babel.config.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// @ts-check
|
||||
|
||||
import path from "node:path";
|
||||
|
||||
import _ from "lodash";
|
||||
|
||||
/** @type {Parameters<typeof import("babel-plugin-formatjs").default>[1]} */
|
||||
const formatJsOptions = {
|
||||
additionalFunctionNames: ["$t", "$t_html"],
|
||||
overrideIdFn: (_id, defaultMessage) => defaultMessage ?? "",
|
||||
};
|
||||
|
||||
/** @type {import("@babel/preset-env").Options} */
|
||||
const presetEnvOptions = {
|
||||
shippedProposals: true,
|
||||
};
|
||||
|
||||
/** @type {import("@babel/core").PluginItem} */
|
||||
const istanbulPlugin = ["istanbul", {exclude: []}];
|
||||
|
||||
/** @type {import("@babel/core").InputOptions} */
|
||||
const config = {
|
||||
only: [new RegExp("^" + _.escapeRegExp(path.resolve(import.meta.dirname, "src") + path.sep))],
|
||||
plugins: [
|
||||
["formatjs", formatJsOptions],
|
||||
["polyfill-corejs3", {method: "usage-global", version: "3.49"}],
|
||||
],
|
||||
presets: [["@babel/preset-env", presetEnvOptions], "@babel/typescript"],
|
||||
env: {
|
||||
test: {
|
||||
plugins: [
|
||||
...(process.env["USING_INSTRUMENTED_CODE"] ? [istanbulPlugin] : []),
|
||||
["@babel/plugin-transform-modules-commonjs", {lazy: () => true}],
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
125
web/debug-require-webpack-plugin.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// This plugin exposes a version of require() to the browser console to assist
|
||||
// debugging. It also exposes the list of modules it knows about as the keys
|
||||
// of the require.ids object.
|
||||
|
||||
import path from "node:path";
|
||||
|
||||
import webpack from "webpack";
|
||||
|
||||
export default class DebugRequirePlugin implements webpack.WebpackPluginInstance {
|
||||
apply(compiler: webpack.Compiler): void {
|
||||
const resolved = new Map<string, Set<string>>();
|
||||
const nameSymbol = Symbol("DebugRequirePluginName");
|
||||
type NamedRequest = {
|
||||
[nameSymbol]?: string | undefined;
|
||||
};
|
||||
let debugRequirePath: string | false = false;
|
||||
|
||||
compiler.resolverFactory.hooks.resolver
|
||||
.for("normal")
|
||||
.tap("DebugRequirePlugin", (resolver) => {
|
||||
resolver.getHook("beforeRawModule").tap("DebugRequirePlugin", (req) => {
|
||||
if (!Object.hasOwn(req, nameSymbol)) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
(req as NamedRequest)[nameSymbol] = req.request;
|
||||
}
|
||||
return undefined!;
|
||||
});
|
||||
|
||||
resolver.getHook("beforeRelative").tap("DebugRequirePlugin", (req) => {
|
||||
if (req.path !== false) {
|
||||
const inPath = path.relative(compiler.context, req.path);
|
||||
if (!inPath.startsWith("../") && !Object.hasOwn(req, nameSymbol)) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
(req as NamedRequest)[nameSymbol] = "./" + inPath;
|
||||
}
|
||||
}
|
||||
return undefined!;
|
||||
});
|
||||
|
||||
resolver.getHook("beforeResolved").tap("DebugRequirePlugin", (req) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const name = (req as NamedRequest)[nameSymbol];
|
||||
if (name !== undefined && req.path !== false) {
|
||||
const names = resolved.get(req.path);
|
||||
if (names) {
|
||||
names.add(name);
|
||||
} else {
|
||||
resolved.set(req.path, new Set([name]));
|
||||
}
|
||||
}
|
||||
return undefined!;
|
||||
});
|
||||
});
|
||||
|
||||
compiler.hooks.beforeCompile.tapPromise(
|
||||
"DebugRequirePlugin",
|
||||
async ({normalModuleFactory}) => {
|
||||
const resolver = normalModuleFactory.getResolver("normal");
|
||||
debugRequirePath = await new Promise((resolve) => {
|
||||
resolver.resolve(
|
||||
{},
|
||||
import.meta.dirname,
|
||||
"./debug-require.cjs",
|
||||
{},
|
||||
(err?: Error | null, result?: string | false) => {
|
||||
resolve(err ? false : result!);
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
compiler.hooks.compilation.tap("DebugRequirePlugin", (compilation) => {
|
||||
compilation.mainTemplate.hooks.bootstrap.tap(
|
||||
"DebugRequirePlugin",
|
||||
(source: string, chunk: webpack.Chunk) => {
|
||||
if (compilation.chunkGraph === undefined) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const ids: [string, string | number][] = [];
|
||||
let hasDebugRequire = false;
|
||||
compilation.chunkGraph.hasModuleInGraph(
|
||||
chunk,
|
||||
(m) => {
|
||||
if (m instanceof webpack.NormalModule) {
|
||||
const id = compilation.chunkGraph.getModuleId(m);
|
||||
if (id === null) {
|
||||
return false;
|
||||
}
|
||||
if (m.resource === debugRequirePath) {
|
||||
hasDebugRequire = true;
|
||||
}
|
||||
const names = resolved.get(m.resource) ?? [];
|
||||
for (const name of names) {
|
||||
ids.push([
|
||||
m.rawRequest.slice(0, m.rawRequest.lastIndexOf("!") + 1) +
|
||||
name,
|
||||
id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
() => true,
|
||||
);
|
||||
|
||||
if (!hasDebugRequire) {
|
||||
return source;
|
||||
}
|
||||
|
||||
ids.sort(([a], [b]) => Number(a > b) - Number(a < b));
|
||||
return webpack.Template.asString([
|
||||
source,
|
||||
`__webpack_require__.debugRequireIds = ${JSON.stringify(
|
||||
Object.fromEntries(ids),
|
||||
null,
|
||||
"\t",
|
||||
)};`,
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
19
web/debug-require.cjs
Normal file
@@ -0,0 +1,19 @@
|
||||
"use strict";
|
||||
|
||||
/* global __webpack_require__ */
|
||||
|
||||
function debugRequire(request) {
|
||||
if (!Object.prototype.hasOwnProperty.call(debugRequire.ids, request)) {
|
||||
throw new Error("Cannot find module '" + request + "'");
|
||||
}
|
||||
var moduleId = debugRequire.ids[request];
|
||||
if (!Object.prototype.hasOwnProperty.call(__webpack_require__.m, moduleId)) {
|
||||
throw new Error("Module '" + request + "' has not been loaded yet");
|
||||
}
|
||||
return __webpack_require__(moduleId);
|
||||
}
|
||||
|
||||
debugRequire.r = __webpack_require__;
|
||||
debugRequire.ids = __webpack_require__.debugRequireIds;
|
||||
|
||||
module.exports = debugRequire;
|
||||
276
web/e2e-tests/admin.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {Page} from "puppeteer";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
|
||||
async function submit_announcements_stream_settings(page: Page): Promise<void> {
|
||||
await page.waitForSelector('#org-notifications .save-button[data-status="unsaved"]', {
|
||||
visible: true,
|
||||
});
|
||||
|
||||
const save_button = "#org-notifications .save-button";
|
||||
await page.waitForFunction(
|
||||
(save_button: string) => {
|
||||
const button = document.querySelector(save_button);
|
||||
return button && button.textContent?.trim() === "Save changes";
|
||||
},
|
||||
{},
|
||||
save_button,
|
||||
);
|
||||
await page.click(save_button);
|
||||
|
||||
await page.waitForSelector('#org-notifications .save-button[data-status="saved"]', {
|
||||
visible: true,
|
||||
});
|
||||
await page.waitForFunction(
|
||||
(save_button: string) => {
|
||||
const button = document.querySelector(save_button);
|
||||
return button && button.textContent?.trim() === "Saved";
|
||||
},
|
||||
{},
|
||||
save_button,
|
||||
);
|
||||
|
||||
await page.waitForSelector("#org-notifications .save-button", {hidden: true});
|
||||
}
|
||||
|
||||
async function test_change_new_stream_announcements_stream(page: Page): Promise<void> {
|
||||
await page.waitForSelector(
|
||||
"#realm_new_stream_announcements_stream_id_widget.dropdown-widget-button",
|
||||
{visible: true},
|
||||
);
|
||||
await page.click("#realm_new_stream_announcements_stream_id_widget.dropdown-widget-button");
|
||||
await page.waitForSelector(".dropdown-list-container", {
|
||||
visible: true,
|
||||
});
|
||||
|
||||
await page.waitForSelector(".dropdown-list-search-input", {visible: true});
|
||||
await page.type(".dropdown-list-search-input", "rome");
|
||||
|
||||
const rome_in_dropdown = await page.waitForSelector(
|
||||
`xpath///*[${common.has_class_x("list-item")}][normalize-space()="Rome"]`,
|
||||
{visible: true},
|
||||
);
|
||||
assert.ok(rome_in_dropdown);
|
||||
await rome_in_dropdown.click();
|
||||
|
||||
await submit_announcements_stream_settings(page);
|
||||
}
|
||||
|
||||
async function test_change_signup_announcements_stream(page: Page): Promise<void> {
|
||||
await page.waitForSelector(
|
||||
"#realm_signup_announcements_stream_id_widget.dropdown-widget-button",
|
||||
{visible: true},
|
||||
);
|
||||
await page.click("#realm_signup_announcements_stream_id_widget.dropdown-widget-button");
|
||||
await page.waitForSelector(".dropdown-list-container", {
|
||||
visible: true,
|
||||
});
|
||||
|
||||
await page.waitForSelector(".dropdown-list-search-input", {visible: true});
|
||||
await page.type(".dropdown-list-search-input", "rome");
|
||||
|
||||
const rome_in_dropdown = await page.waitForSelector(
|
||||
`xpath///*[${common.has_class_x("list-item")}][normalize-space()="Rome"]`,
|
||||
{visible: true},
|
||||
);
|
||||
assert.ok(rome_in_dropdown);
|
||||
await rome_in_dropdown.click();
|
||||
|
||||
await submit_announcements_stream_settings(page);
|
||||
}
|
||||
|
||||
async function test_change_zulip_update_announcements_stream(page: Page): Promise<void> {
|
||||
await page.waitForSelector(
|
||||
"#realm_zulip_update_announcements_stream_id_widget.dropdown-widget-button",
|
||||
{visible: true},
|
||||
);
|
||||
await page.click("#realm_zulip_update_announcements_stream_id_widget.dropdown-widget-button");
|
||||
await page.waitForSelector(".dropdown-list-container", {
|
||||
visible: true,
|
||||
});
|
||||
|
||||
await page.waitForSelector(".dropdown-list-search-input", {visible: true});
|
||||
await page.type(".dropdown-list-search-input", "rome");
|
||||
|
||||
const rome_in_dropdown = await page.waitForSelector(
|
||||
`xpath///*[${common.has_class_x("list-item")}][normalize-space()="Rome"]`,
|
||||
{visible: true},
|
||||
);
|
||||
assert.ok(rome_in_dropdown);
|
||||
await rome_in_dropdown.click();
|
||||
|
||||
await submit_announcements_stream_settings(page);
|
||||
}
|
||||
|
||||
async function test_save_joining_organization_change_worked(page: Page): Promise<void> {
|
||||
const saved_status = '#org-join-settings .save-button[data-status="saved"]';
|
||||
await page.waitForSelector(saved_status, {
|
||||
visible: true,
|
||||
});
|
||||
await page.waitForSelector(saved_status, {hidden: true});
|
||||
}
|
||||
|
||||
async function submit_joining_organization_change(page: Page): Promise<void> {
|
||||
const save_button = "#org-join-settings .save-button";
|
||||
await page.waitForSelector(save_button, {visible: true});
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, save_button),
|
||||
"Save changes",
|
||||
"Save button didn't appear for permissions change.",
|
||||
);
|
||||
await page.waitForSelector(save_button, {visible: true});
|
||||
await page.click(save_button);
|
||||
|
||||
await test_save_joining_organization_change_worked(page);
|
||||
}
|
||||
|
||||
async function test_set_new_user_threshold_to_three_days(page: Page): Promise<void> {
|
||||
console.log("Test setting new user threshold to three days.");
|
||||
await page.waitForSelector("#id_realm_waiting_period_threshold", {visible: true});
|
||||
await page.select("#id_realm_waiting_period_threshold", "3");
|
||||
await submit_joining_organization_change(page);
|
||||
}
|
||||
|
||||
async function test_set_new_user_threshold_to_N_days(page: Page): Promise<void> {
|
||||
console.log("Test setting new user threshold to N days.");
|
||||
await page.waitForSelector("#id_realm_waiting_period_threshold", {visible: true});
|
||||
await page.select("#id_realm_waiting_period_threshold", "custom_period");
|
||||
|
||||
const N = "10";
|
||||
await common.clear_and_type(page, "#id_realm_waiting_period_threshold_custom_input", N);
|
||||
await submit_joining_organization_change(page);
|
||||
}
|
||||
|
||||
async function test_organization_permissions(page: Page): Promise<void> {
|
||||
await page.click("li[data-section='organization-permissions']");
|
||||
|
||||
// Test temporarily disabled 2024-02-25 due to nondeterminsitic failures.
|
||||
// See https://chat.zulip.org/#narrow/channel/43-automated-testing/topic/main.20failing/near/1743361
|
||||
console.log("Skipping", test_set_new_user_threshold_to_three_days);
|
||||
console.log("Skipping", test_set_new_user_threshold_to_N_days);
|
||||
}
|
||||
|
||||
async function test_add_emoji(page: Page): Promise<void> {
|
||||
await common.fill_form(page, "#add-custom-emoji-form", {name: "zulip logo"});
|
||||
|
||||
const emoji_upload_handle = await page.$("input#emoji_file_input");
|
||||
assert.ok(emoji_upload_handle);
|
||||
await emoji_upload_handle.uploadFile("static/images/logo/zulip-icon-128x128.png");
|
||||
await page.click("#add-custom-emoji-modal .dialog_submit_button");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
|
||||
await page.waitForSelector("tr#emoji_zulip_logo", {visible: true});
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, "tr#emoji_zulip_logo .emoji_name"),
|
||||
"zulip logo",
|
||||
"Emoji name incorrectly saved.",
|
||||
);
|
||||
await page.waitForSelector("tr#emoji_zulip_logo img", {visible: true});
|
||||
}
|
||||
|
||||
async function test_delete_emoji(page: Page): Promise<void> {
|
||||
await page.click("tr#emoji_zulip_logo button.delete");
|
||||
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
await page.click("#confirm_deactivate_custom_emoji_modal .dialog_submit_button");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
|
||||
// assert the emoji is deleted.
|
||||
await page.waitForSelector("tr#emoji_zulip_logo", {hidden: true});
|
||||
}
|
||||
|
||||
async function test_custom_realm_emoji(page: Page): Promise<void> {
|
||||
await page.click("li[data-section='emoji-settings']");
|
||||
await page.click("#add-custom-emoji-button");
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
|
||||
await test_add_emoji(page);
|
||||
await test_delete_emoji(page);
|
||||
}
|
||||
|
||||
async function test_upload_realm_icon_image(page: Page): Promise<void> {
|
||||
const upload_handle = await page.$("#realm-icon-upload-widget input.image_file_input");
|
||||
assert.ok(upload_handle);
|
||||
await upload_handle.uploadFile("static/images/logo/zulip-icon-128x128.png");
|
||||
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
await page.click("#uppy-editor .dialog_submit_button");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
await page.waitForSelector(
|
||||
'#realm-icon-upload-widget .image-block[src^="/user_avatars/2/realm/icon.png?version=2"]',
|
||||
{visible: true},
|
||||
);
|
||||
}
|
||||
|
||||
async function delete_realm_icon(page: Page): Promise<void> {
|
||||
await page.click("li[data-section='organization-profile']");
|
||||
await page.click("#realm-icon-upload-widget .image-delete-button");
|
||||
|
||||
await page.waitForSelector("#realm-icon-upload-widget .image-delete-button", {hidden: true});
|
||||
}
|
||||
|
||||
async function test_organization_profile(page: Page): Promise<void> {
|
||||
await page.click("li[data-section='organization-profile']");
|
||||
const gravatar_selector =
|
||||
'#realm-icon-upload-widget .image-block[src^="https://secure.gravatar.com/avatar/"]';
|
||||
await page.waitForSelector(gravatar_selector, {visible: true});
|
||||
await page.waitForSelector("#realm-icon-upload-widget .image-delete-button", {hidden: true});
|
||||
|
||||
await test_upload_realm_icon_image(page);
|
||||
await page.waitForSelector("#realm-icon-upload-widget .image-delete-button", {visible: true});
|
||||
|
||||
await delete_realm_icon(page);
|
||||
await page.waitForSelector("#realm-icon-upload-widget .image-delete-button", {hidden: true});
|
||||
await page.waitForSelector(gravatar_selector, {visible: true});
|
||||
}
|
||||
|
||||
async function test_authentication_methods(page: Page): Promise<void> {
|
||||
await page.click("li[data-section='auth-methods']");
|
||||
await page.waitForSelector(".method_row[data-method='Google'] input[type='checkbox'] + span", {
|
||||
visible: true,
|
||||
});
|
||||
|
||||
await page.click(".method_row[data-method='Google'] input[type='checkbox'] + span");
|
||||
const save_button = "#org-auth_settings .save-button";
|
||||
assert.strictEqual(await common.get_text_from_selector(page, save_button), "Save changes");
|
||||
await page.click(save_button);
|
||||
|
||||
// Leave the page and return.
|
||||
const settings_dropdown = "#settings-dropdown";
|
||||
await page.click(settings_dropdown);
|
||||
|
||||
await common.manage_organization(page);
|
||||
await page.click("li[data-section='auth-methods']");
|
||||
|
||||
// Test setting was saved.
|
||||
await page.waitForSelector(".method_row[data-method='Google'] input[type='checkbox'] + span", {
|
||||
visible: true,
|
||||
});
|
||||
await page.waitForSelector(
|
||||
".method_row[data-method='Google'] input[type='checkbox']:not(:checked)",
|
||||
);
|
||||
}
|
||||
|
||||
async function admin_test(page: Page): Promise<void> {
|
||||
await common.log_in(page);
|
||||
|
||||
await common.manage_organization(page);
|
||||
await test_change_new_stream_announcements_stream(page);
|
||||
await test_change_signup_announcements_stream(page);
|
||||
await test_change_zulip_update_announcements_stream(page);
|
||||
|
||||
await test_organization_permissions(page);
|
||||
// Currently, Firefox (with puppeteer) does not support file upload:
|
||||
// https://github.com/puppeteer/puppeteer/issues/6688.
|
||||
// Until that is resolved upstream, we need to skip the tests that involve
|
||||
// doing file upload on Firefox.
|
||||
if (!common.is_firefox) {
|
||||
await test_custom_realm_emoji(page);
|
||||
await test_organization_profile(page);
|
||||
}
|
||||
await test_authentication_methods(page);
|
||||
}
|
||||
|
||||
await common.run_test(admin_test);
|
||||
243
web/e2e-tests/compose.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {Page} from "puppeteer";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
|
||||
async function check_compose_form_empty(page: Page): Promise<void> {
|
||||
await common.check_compose_state(page, {
|
||||
stream_name: "",
|
||||
topic: "",
|
||||
content: "",
|
||||
});
|
||||
}
|
||||
|
||||
async function close_compose_box(page: Page): Promise<void> {
|
||||
const recipient_dropdown_visible = (await page.$(".dropdown-list-container")) !== null;
|
||||
|
||||
if (recipient_dropdown_visible) {
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForSelector(".dropdown-list-container", {hidden: true});
|
||||
}
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForSelector("#compose-textarea", {hidden: true});
|
||||
}
|
||||
|
||||
function get_message_selector(text: string): string {
|
||||
return `xpath/(//p[text()='${text}'])[last()]`;
|
||||
}
|
||||
|
||||
async function test_send_messages(page: Page): Promise<void> {
|
||||
const initial_msgs_count = (await page.$$(".message-list .message_row")).length;
|
||||
|
||||
await common.send_multiple_messages(page, [
|
||||
{stream_name: "Verona", topic: "Reply test", content: "Compose stream reply test"},
|
||||
{recipient: "cordelia@zulip.com", content: "Compose direct message reply test"},
|
||||
]);
|
||||
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
const message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(
|
||||
`.message-list[data-message-list-id='${message_list_id}'] .message_row`,
|
||||
{visible: true},
|
||||
);
|
||||
assert.equal((await page.$$(".message-list .message_row")).length, initial_msgs_count + 2);
|
||||
}
|
||||
|
||||
async function test_stream_compose_keyboard_shortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press("KeyC");
|
||||
await page.waitForSelector("#stream_message_recipient_topic", {visible: true});
|
||||
await check_compose_form_empty(page);
|
||||
await close_compose_box(page);
|
||||
}
|
||||
|
||||
async function test_private_message_compose_shortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press("KeyX");
|
||||
await page.waitForSelector("#private_message_recipient", {visible: true});
|
||||
await common.pm_recipient.expect(page, "");
|
||||
await close_compose_box(page);
|
||||
}
|
||||
|
||||
async function test_keyboard_shortcuts(page: Page): Promise<void> {
|
||||
await test_stream_compose_keyboard_shortcut(page);
|
||||
await test_private_message_compose_shortcut(page);
|
||||
}
|
||||
|
||||
async function test_reply_by_click_prepopulates_stream_topic_names(page: Page): Promise<void> {
|
||||
const stream_message_selector = get_message_selector("Compose stream reply test");
|
||||
const stream_message = await page.waitForSelector(stream_message_selector, {visible: true});
|
||||
assert.ok(stream_message !== null);
|
||||
// we chose only the last element make sure we don't click on any duplicates.
|
||||
await stream_message.click();
|
||||
await common.check_compose_state(page, {
|
||||
stream_name: "Verona",
|
||||
topic: "Reply test",
|
||||
content: "",
|
||||
});
|
||||
await close_compose_box(page);
|
||||
}
|
||||
|
||||
async function test_reply_by_click_prepopulates_private_message_recipient(
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
const private_message = await page.$(get_message_selector("Compose direct message reply test"));
|
||||
assert.ok(private_message !== null);
|
||||
await private_message.click();
|
||||
await page.waitForSelector("#private_message_recipient", {visible: true});
|
||||
const email = await common.get_internal_email_from_name(page, common.fullname.cordelia);
|
||||
assert.ok(email !== undefined);
|
||||
await common.pm_recipient.expect(page, email);
|
||||
await close_compose_box(page);
|
||||
}
|
||||
|
||||
async function test_reply_with_r_shortcut(page: Page): Promise<void> {
|
||||
// The last message(private) in the narrow is currently selected as a result of previous tests.
|
||||
// Now we go up and open compose box with r key.
|
||||
await page.keyboard.press("KeyK");
|
||||
await page.keyboard.press("KeyR");
|
||||
await common.check_compose_state(page, {
|
||||
stream_name: "Verona",
|
||||
topic: "Reply test",
|
||||
content: "",
|
||||
});
|
||||
}
|
||||
|
||||
async function test_open_close_compose_box(page: Page): Promise<void> {
|
||||
await page.waitForSelector("#stream_message_recipient_topic", {visible: true});
|
||||
await close_compose_box(page);
|
||||
await page.waitForSelector("#stream_message_recipient_topic", {hidden: true});
|
||||
|
||||
await page.keyboard.press("KeyX");
|
||||
await page.waitForSelector("#compose-direct-recipient", {visible: true});
|
||||
await close_compose_box(page);
|
||||
await page.waitForSelector("#compose-direct-recipient", {hidden: true});
|
||||
}
|
||||
|
||||
async function test_narrow_to_private_messages_with_cordelia(page: Page): Promise<void> {
|
||||
const you_and_cordelia_selector =
|
||||
'*[data-tippy-content="Go to direct messages with Cordelia, Lear\'s daughter"]';
|
||||
// For some unknown reason page.click() isn't working here.
|
||||
await page.evaluate((selector: string) => {
|
||||
document.querySelector<HTMLElement>(selector)!.click();
|
||||
}, you_and_cordelia_selector);
|
||||
const cordelia_user_id = await common.get_user_id_from_name(page, "Cordelia, Lear's daughter");
|
||||
const pm_list_selector = `li[data-user-ids-string="${cordelia_user_id}"].dm-list-item.active-sub-filter`;
|
||||
await page.waitForSelector(pm_list_selector, {visible: true});
|
||||
await close_compose_box(page);
|
||||
|
||||
await page.keyboard.press("KeyC");
|
||||
await page.waitForSelector("#compose", {visible: true});
|
||||
await page.waitForSelector(`.dropdown-list-container .list-item`, {visible: true});
|
||||
await close_compose_box(page);
|
||||
}
|
||||
|
||||
async function test_send_multirecipient_pm_from_cordelia_pm_narrow(page: Page): Promise<void> {
|
||||
const recipients = ["cordelia@zulip.com", "othello@zulip.com"];
|
||||
const multiple_recipients_pm = "A direct message group to check spaces";
|
||||
await common.send_message(page, "private", {
|
||||
recipient: recipients.join(", "),
|
||||
content: multiple_recipients_pm,
|
||||
});
|
||||
|
||||
// Go back to the combined feed view and make sure all messages are loaded.
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
const message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(
|
||||
`.message-list[data-message-list-id='${message_list_id}'] .message_row`,
|
||||
{visible: true},
|
||||
);
|
||||
const pm = await page.waitForSelector(
|
||||
`xpath/(//*[${common.has_class_x(
|
||||
"messagebox",
|
||||
)} and contains(normalize-space(), "${multiple_recipients_pm}") and count(.//*[${common.has_class_x(
|
||||
"star",
|
||||
)}])>0])[last()]`,
|
||||
);
|
||||
assert.ok(pm !== null);
|
||||
await pm.click();
|
||||
await page.waitForSelector("#compose-textarea", {visible: true});
|
||||
const recipient_internal_emails = [
|
||||
await common.get_internal_email_from_name(page, common.fullname.cordelia),
|
||||
await common.get_internal_email_from_name(page, common.fullname.othello),
|
||||
].join(",");
|
||||
await common.pm_recipient.expect(page, recipient_internal_emails);
|
||||
}
|
||||
|
||||
const markdown_preview_button = "#compose .markdown_preview";
|
||||
const markdown_preview_hide_button = "#compose .undo_markdown_preview";
|
||||
|
||||
async function test_markdown_preview_buttons_visibility(page: Page): Promise<void> {
|
||||
await page.waitForSelector(markdown_preview_button, {visible: true});
|
||||
await page.waitForSelector(markdown_preview_hide_button, {hidden: true});
|
||||
|
||||
// verify if Markdown preview button works.
|
||||
await page.click(markdown_preview_button);
|
||||
await page.waitForSelector(markdown_preview_button, {hidden: true});
|
||||
await page.waitForSelector(markdown_preview_hide_button, {visible: true});
|
||||
|
||||
// verify if hide button works.
|
||||
await page.click(markdown_preview_hide_button);
|
||||
await page.waitForSelector(markdown_preview_button, {visible: true});
|
||||
await page.waitForSelector(markdown_preview_hide_button, {hidden: true});
|
||||
}
|
||||
|
||||
async function test_markdown_preview_without_any_content(page: Page): Promise<void> {
|
||||
await page.click("#compose .markdown_preview");
|
||||
await page.waitForSelector("#compose .undo_markdown_preview", {visible: true});
|
||||
const markdown_preview_element = await page.$("#compose .preview_content");
|
||||
assert.ok(markdown_preview_element);
|
||||
assert.equal(
|
||||
await page.evaluate((element: Element) => element.textContent, markdown_preview_element),
|
||||
"Nothing to preview",
|
||||
);
|
||||
await page.click("#compose .undo_markdown_preview");
|
||||
}
|
||||
|
||||
async function test_markdown_rendering(page: Page): Promise<void> {
|
||||
await page.waitForSelector("#compose .markdown_preview", {visible: true});
|
||||
assert.equal(await common.get_text_from_selector(page, "#compose .preview_content"), "");
|
||||
await common.fill_form(page, 'form[action^="/json/messages"]', {
|
||||
content: "**Markdown preview** >> Test for Markdown preview",
|
||||
});
|
||||
await page.click("#compose .markdown_preview");
|
||||
const preview_content = await page.waitForSelector(
|
||||
`xpath///*[@id="compose"]//*[${common.has_class_x(
|
||||
"preview_content",
|
||||
)} and normalize-space()!=""]`,
|
||||
{visible: true},
|
||||
);
|
||||
assert.ok(preview_content !== null);
|
||||
const expected_markdown_html =
|
||||
"<p><strong>Markdown preview</strong> >> Test for Markdown preview</p>";
|
||||
assert.equal(
|
||||
await (await preview_content.getProperty("innerHTML")).jsonValue(),
|
||||
expected_markdown_html,
|
||||
);
|
||||
}
|
||||
|
||||
async function test_markdown_preview(page: Page): Promise<void> {
|
||||
await test_markdown_preview_buttons_visibility(page);
|
||||
await test_markdown_preview_without_any_content(page);
|
||||
await test_markdown_rendering(page);
|
||||
}
|
||||
|
||||
async function compose_tests(page: Page): Promise<void> {
|
||||
await common.log_in(page);
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
const message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(
|
||||
`.message-list[data-message-list-id='${message_list_id}'] .message_row`,
|
||||
{visible: true},
|
||||
);
|
||||
await test_send_messages(page);
|
||||
await test_keyboard_shortcuts(page);
|
||||
await test_reply_by_click_prepopulates_stream_topic_names(page);
|
||||
await test_reply_by_click_prepopulates_private_message_recipient(page);
|
||||
await test_reply_with_r_shortcut(page);
|
||||
await test_open_close_compose_box(page);
|
||||
await test_narrow_to_private_messages_with_cordelia(page);
|
||||
await test_send_multirecipient_pm_from_cordelia_pm_narrow(page);
|
||||
await test_markdown_preview(page);
|
||||
}
|
||||
|
||||
await common.run_test(compose_tests);
|
||||
313
web/e2e-tests/copy-messages.test.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {Page} from "puppeteer";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
|
||||
type PartialSelectionConfig = {
|
||||
select_start_message_partially: boolean;
|
||||
select_end_message_partially: boolean;
|
||||
start_text_node_offset?: number;
|
||||
end_text_node_offset?: number;
|
||||
};
|
||||
async function copy_messages(
|
||||
page: Page,
|
||||
start_message: string,
|
||||
end_message: string,
|
||||
partial_selection_config?: PartialSelectionConfig,
|
||||
): Promise<string[]> {
|
||||
return await page.evaluate(
|
||||
(
|
||||
start_message: string,
|
||||
end_message: string,
|
||||
partial_selection_config?: PartialSelectionConfig,
|
||||
) => {
|
||||
function get_message_node(message: string): Element {
|
||||
return [...document.querySelectorAll(".message-list .message_content")].find(
|
||||
(node) => node.textContent?.trim() === message,
|
||||
)!;
|
||||
}
|
||||
|
||||
// select messages from start_message to end_message
|
||||
const selectedRange = document.createRange();
|
||||
if (partial_selection_config?.select_start_message_partially) {
|
||||
const offset = partial_selection_config.start_text_node_offset!;
|
||||
const start_message_text_node =
|
||||
get_message_node(start_message).querySelector("p")?.firstChild;
|
||||
if (!(start_message_text_node instanceof Text)) {
|
||||
throw new TypeError("Expected a Text node");
|
||||
}
|
||||
selectedRange.setStart(start_message_text_node, offset);
|
||||
} else {
|
||||
selectedRange.setStartBefore(get_message_node(start_message));
|
||||
}
|
||||
if (partial_selection_config?.select_end_message_partially) {
|
||||
const offset = partial_selection_config.end_text_node_offset!;
|
||||
const end_message_text_node =
|
||||
get_message_node(end_message).querySelector("p")?.firstChild;
|
||||
if (!(end_message_text_node instanceof Text)) {
|
||||
throw new TypeError("Expected a Text node");
|
||||
}
|
||||
// For the last message, the offset will be from the end of the message,
|
||||
// just like how selecting text in the browser would work.
|
||||
selectedRange.setEnd(end_message_text_node, end_message_text_node.length - offset);
|
||||
} else {
|
||||
selectedRange.setEndAfter(get_message_node(end_message));
|
||||
}
|
||||
window.getSelection()!.removeAllRanges();
|
||||
window.getSelection()!.addRange(selectedRange);
|
||||
|
||||
// emulate copy event
|
||||
const clipboard_data = new DataTransfer();
|
||||
const copy_event = new ClipboardEvent("copy", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData: clipboard_data,
|
||||
});
|
||||
document.dispatchEvent(copy_event);
|
||||
|
||||
const copied_html = clipboard_data.getData("text/html");
|
||||
|
||||
// Convert the copied HTML into separate message strings
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(copied_html, "text/html");
|
||||
|
||||
// Empty paragraphs are inserted only to separate consecutive
|
||||
// messages with a blank line; skip them when collecting lines.
|
||||
return [...doc.body.children]
|
||||
.map((el) => el.textContent.trim())
|
||||
.filter((line) => line !== "");
|
||||
},
|
||||
start_message,
|
||||
end_message,
|
||||
partial_selection_config,
|
||||
);
|
||||
}
|
||||
|
||||
async function test_copying_first_message_from_topic(page: Page): Promise<void> {
|
||||
const actual_copied_lines = await copy_messages(page, "copy paste test C", "copy paste test C");
|
||||
const expected_copied_lines: string[] = [];
|
||||
assert.deepStrictEqual(actual_copied_lines, expected_copied_lines);
|
||||
}
|
||||
|
||||
async function test_copying_last_message_from_topic(page: Page): Promise<void> {
|
||||
const actual_copied_lines = await copy_messages(page, "copy paste test E", "copy paste test E");
|
||||
const expected_copied_lines: string[] = [];
|
||||
assert.deepStrictEqual(actual_copied_lines, expected_copied_lines);
|
||||
}
|
||||
|
||||
async function test_copying_first_two_messages_from_topic(page: Page): Promise<void> {
|
||||
const actual_copied_lines = await copy_messages(page, "copy paste test C", "copy paste test D");
|
||||
const expected_copied_lines = [
|
||||
"Desdemona:",
|
||||
"copy paste test C",
|
||||
"Desdemona:",
|
||||
"copy paste test D",
|
||||
];
|
||||
assert.deepStrictEqual(actual_copied_lines, expected_copied_lines);
|
||||
}
|
||||
|
||||
async function test_copying_all_messages_from_topic(page: Page): Promise<void> {
|
||||
const actual_copied_lines = await copy_messages(page, "copy paste test C", "copy paste test E");
|
||||
const expected_copied_lines = [
|
||||
"Desdemona:",
|
||||
"copy paste test C",
|
||||
"Desdemona:",
|
||||
"copy paste test D",
|
||||
"Desdemona:",
|
||||
"copy paste test E",
|
||||
];
|
||||
assert.deepStrictEqual(actual_copied_lines, expected_copied_lines);
|
||||
}
|
||||
|
||||
async function test_copying_last_from_prev_first_from_next(page: Page): Promise<void> {
|
||||
const actual_copied_lines = await copy_messages(page, "copy paste test B", "copy paste test C");
|
||||
const expected_copied_lines = [
|
||||
"Verona > copy-paste-topic #1 | Today",
|
||||
"Desdemona:",
|
||||
"copy paste test B",
|
||||
"Verona > copy-paste-topic #2 | Today",
|
||||
"Desdemona:",
|
||||
"copy paste test C",
|
||||
];
|
||||
assert.deepStrictEqual(actual_copied_lines, expected_copied_lines);
|
||||
}
|
||||
|
||||
async function test_copying_last_from_prev_all_from_next(page: Page): Promise<void> {
|
||||
const actual_copied_lines = await copy_messages(page, "copy paste test B", "copy paste test E");
|
||||
const expected_copied_lines = [
|
||||
"Verona > copy-paste-topic #1 | Today",
|
||||
"Desdemona:",
|
||||
"copy paste test B",
|
||||
"Verona > copy-paste-topic #2 | Today",
|
||||
"Desdemona:",
|
||||
"copy paste test C",
|
||||
"Desdemona:",
|
||||
"copy paste test D",
|
||||
"Desdemona:",
|
||||
"copy paste test E",
|
||||
];
|
||||
assert.deepStrictEqual(actual_copied_lines, expected_copied_lines);
|
||||
}
|
||||
|
||||
async function test_copying_all_from_prev_first_from_next(page: Page): Promise<void> {
|
||||
const actual_copied_lines = await copy_messages(page, "copy paste test A", "copy paste test C");
|
||||
const expected_copied_lines = [
|
||||
"Verona > copy-paste-topic #1 | Today",
|
||||
"Desdemona:",
|
||||
"copy paste test A",
|
||||
"Desdemona:",
|
||||
"copy paste test B",
|
||||
"Verona > copy-paste-topic #2 | Today",
|
||||
"Desdemona:",
|
||||
"copy paste test C",
|
||||
];
|
||||
assert.deepStrictEqual(actual_copied_lines, expected_copied_lines);
|
||||
}
|
||||
|
||||
async function test_copying_messages_from_several_topics(page: Page): Promise<void> {
|
||||
const actual_copied_lines = await copy_messages(page, "copy paste test B", "copy paste test F");
|
||||
const expected_copied_lines = [
|
||||
"Verona > copy-paste-topic #1 | Today",
|
||||
"Desdemona:",
|
||||
"copy paste test B",
|
||||
"Verona > copy-paste-topic #2 | Today",
|
||||
"Desdemona:",
|
||||
"copy paste test C",
|
||||
"Desdemona:",
|
||||
"copy paste test D",
|
||||
"Desdemona:",
|
||||
"copy paste test E",
|
||||
"Verona > copy-paste-topic #3 | Today",
|
||||
"Desdemona:",
|
||||
"copy paste test F",
|
||||
];
|
||||
assert.deepStrictEqual(actual_copied_lines, expected_copied_lines);
|
||||
}
|
||||
|
||||
async function test_timestamp_clipboard_has_datetime(page: Page): Promise<void> {
|
||||
// Verify that copying a rendered timestamp injects <span data-datetime> into the
|
||||
// selection HTML so Chrome's clipboard serializer cannot silently drop the datetime.
|
||||
const copied_html = await page.evaluate(() => {
|
||||
const time_el = document.querySelector<HTMLElement>(
|
||||
'.message-list time[datetime="2026-05-23T17:30:00Z"]',
|
||||
);
|
||||
if (!time_el) {
|
||||
return null;
|
||||
}
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(time_el);
|
||||
window.getSelection()!.removeAllRanges();
|
||||
window.getSelection()!.addRange(range);
|
||||
|
||||
// Dispatch copy: copy_handler runs improve_time_selection_range, which
|
||||
// injects <span data-datetime> into the DOM and expands the range to
|
||||
// cover the full <time>. For a single-message selection copy_handler
|
||||
// returns false (browser handles clipboard natively), so the DataTransfer
|
||||
// stays empty — but the selection range now contains the mutated DOM.
|
||||
document.dispatchEvent(
|
||||
new ClipboardEvent("copy", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData: new DataTransfer(),
|
||||
}),
|
||||
);
|
||||
|
||||
// Serialize the mutated, expanded selection: this is what Chrome writes
|
||||
// to the clipboard. Even when Chrome strips <time>, <span data-datetime>
|
||||
// survives as a plain span and the paste handler can recover <time:ISO>.
|
||||
const div = document.createElement("div");
|
||||
div.append(window.getSelection()!.getRangeAt(0).cloneContents());
|
||||
return div.innerHTML;
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
copied_html?.includes('data-datetime="2026-05-23T17:30:00Z"'),
|
||||
`Expected data-datetime="2026-05-23T17:30:00Z" in clipboard HTML, got: ${copied_html}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function test_multiple_message_selection_with_partially_selected_bookend_messages(
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
const actual_copied_lines = await copy_messages(
|
||||
page,
|
||||
"copy paste test B",
|
||||
"copy paste test F",
|
||||
{
|
||||
select_start_message_partially: true,
|
||||
select_end_message_partially: true,
|
||||
start_text_node_offset: 5,
|
||||
end_text_node_offset: 7,
|
||||
},
|
||||
);
|
||||
const expected_copied_lines = [
|
||||
"Verona > copy-paste-topic #1 | Today",
|
||||
"Desdemona:",
|
||||
// w/o partial selection: "copy paste test B",
|
||||
"...paste test B",
|
||||
"Verona > copy-paste-topic #2 | Today",
|
||||
"Desdemona:",
|
||||
"copy paste test C",
|
||||
"Desdemona:",
|
||||
"copy paste test D",
|
||||
"Desdemona:",
|
||||
"copy paste test E",
|
||||
"Verona > copy-paste-topic #3 | Today",
|
||||
"Desdemona:",
|
||||
// w/o partial selection: "copy paste test F",
|
||||
"copy paste...",
|
||||
];
|
||||
assert.deepStrictEqual(actual_copied_lines, expected_copied_lines);
|
||||
}
|
||||
|
||||
async function copy_paste_test(page: Page): Promise<void> {
|
||||
await common.log_in(page);
|
||||
await common.send_multiple_messages(page, [
|
||||
{
|
||||
stream_name: "Verona",
|
||||
topic: "copy-paste-topic #0",
|
||||
content: "<time:2026-05-23T17:30:00Z>",
|
||||
},
|
||||
|
||||
{stream_name: "Verona", topic: "copy-paste-topic #1", content: "copy paste test A"},
|
||||
|
||||
{stream_name: "Verona", topic: "copy-paste-topic #1", content: "copy paste test B"},
|
||||
|
||||
{stream_name: "Verona", topic: "copy-paste-topic #2", content: "copy paste test C"},
|
||||
|
||||
{stream_name: "Verona", topic: "copy-paste-topic #2", content: "copy paste test D"},
|
||||
|
||||
{stream_name: "Verona", topic: "copy-paste-topic #2", content: "copy paste test E"},
|
||||
|
||||
{stream_name: "Verona", topic: "copy-paste-topic #3", content: "copy paste test F"},
|
||||
|
||||
{stream_name: "Verona", topic: "copy-paste-topic #3", content: "copy paste test G"},
|
||||
]);
|
||||
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
const message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await common.check_messages_sent(page, message_list_id, [
|
||||
["Verona > copy-paste-topic #0", ["Sat, May 23, 2026, 5:30 PM"]],
|
||||
["Verona > copy-paste-topic #1", ["copy paste test A", "copy paste test B"]],
|
||||
[
|
||||
"Verona > copy-paste-topic #2",
|
||||
["copy paste test C", "copy paste test D", "copy paste test E"],
|
||||
],
|
||||
["Verona > copy-paste-topic #3", ["copy paste test F", "copy paste test G"]],
|
||||
]);
|
||||
console.log("Messages were sent successfully");
|
||||
|
||||
await test_copying_first_message_from_topic(page);
|
||||
await test_copying_last_message_from_topic(page);
|
||||
await test_copying_first_two_messages_from_topic(page);
|
||||
await test_copying_all_messages_from_topic(page);
|
||||
await test_copying_last_from_prev_first_from_next(page);
|
||||
await test_copying_last_from_prev_all_from_next(page);
|
||||
await test_copying_all_from_prev_first_from_next(page);
|
||||
await test_copying_messages_from_several_topics(page);
|
||||
await test_timestamp_clipboard_has_datetime(page);
|
||||
await test_multiple_message_selection_with_partially_selected_bookend_messages(page);
|
||||
}
|
||||
|
||||
await common.run_test(copy_paste_test);
|
||||
97
web/e2e-tests/custom-profile.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {Page} from "puppeteer";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
|
||||
// This will be the row of the custom profile field we add.
|
||||
const profile_field_row = "#admin_profile_fields_table tr:nth-last-child(1)";
|
||||
|
||||
async function test_add_new_profile_field(page: Page): Promise<void> {
|
||||
await page.click("#add-custom-profile-field-button");
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".dialog_heading"),
|
||||
"Add a new custom profile field",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".micromodal .dialog_submit_button"),
|
||||
"Add",
|
||||
);
|
||||
await page.waitForSelector(".admin-profile-field-form", {visible: true});
|
||||
await common.fill_form(page, "form.admin-profile-field-form", {
|
||||
field_type: "1",
|
||||
name: "Teams",
|
||||
});
|
||||
await page.click(".micromodal .dialog_submit_button");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
|
||||
await page.waitForSelector(
|
||||
'xpath///*[@id="admin_profile_fields_table"]//tr[last()]/td[normalize-space()="Teams"]',
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, `${profile_field_row} span.profile_field_type`),
|
||||
"Short text",
|
||||
);
|
||||
}
|
||||
|
||||
async function test_edit_profile_field(page: Page): Promise<void> {
|
||||
await page.click(`${profile_field_row} button.open-edit-form-modal`);
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".dialog_heading"),
|
||||
"Edit custom profile field",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".micromodal .dialog_submit_button"),
|
||||
"Save changes",
|
||||
);
|
||||
await common.fill_form(page, "form.name-setting", {
|
||||
name: "team",
|
||||
});
|
||||
await page.click(".micromodal .dialog_submit_button");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
|
||||
await page.waitForSelector(
|
||||
'xpath///*[@id="admin_profile_fields_table"]//tr[last()]/td[normalize-space()="team"]',
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, `${profile_field_row} span.profile_field_type`),
|
||||
"Short text",
|
||||
);
|
||||
}
|
||||
|
||||
async function test_delete_custom_profile_field(page: Page): Promise<void> {
|
||||
await page.click(`${profile_field_row} button.delete`);
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".dialog_heading"),
|
||||
"Delete custom profile field?",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".micromodal .dialog_submit_button"),
|
||||
"Delete",
|
||||
);
|
||||
await page.click(".micromodal .dialog_submit_button");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
|
||||
await page.waitForSelector("#admin-profile-field-status img", {visible: true});
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, "div#admin-profile-field-status"),
|
||||
"Saved",
|
||||
);
|
||||
}
|
||||
|
||||
async function test_custom_profile(page: Page): Promise<void> {
|
||||
await common.log_in(page);
|
||||
await common.manage_organization(page);
|
||||
|
||||
console.log("Testing custom profile fields");
|
||||
await page.click("li[data-section='profile-field-settings']");
|
||||
|
||||
await test_add_new_profile_field(page);
|
||||
await test_edit_profile_field(page);
|
||||
await test_delete_custom_profile_field(page);
|
||||
}
|
||||
|
||||
await common.run_test(test_custom_profile);
|
||||
46
web/e2e-tests/delete-message.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {Page} from "puppeteer";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
|
||||
async function click_delete_and_return_last_msg_id(page: Page): Promise<string> {
|
||||
const msg = (await page.$$(".message-list .message_row")).at(-1);
|
||||
assert.ok(msg !== undefined);
|
||||
const id = await (await msg.getProperty("id")).jsonValue();
|
||||
await msg.hover();
|
||||
const info = await page.waitForSelector(
|
||||
`#${CSS.escape(id)} .message_control_button.actions_hover`,
|
||||
{visible: true},
|
||||
);
|
||||
assert.ok(info !== null);
|
||||
await info.click();
|
||||
await page.waitForSelector(".delete_message", {visible: true});
|
||||
await page.click(".delete_message");
|
||||
return id;
|
||||
}
|
||||
|
||||
async function delete_message_test(page: Page): Promise<void> {
|
||||
await common.log_in(page);
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
const message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(
|
||||
`.message-list[data-message-list-id='${message_list_id}'] .message_row`,
|
||||
{visible: true},
|
||||
);
|
||||
// Assert that there is only one message list.
|
||||
assert.equal((await page.$$(".message-list")).length, 1);
|
||||
const messages_quantity = (await page.$$(".message-list .message_row")).length;
|
||||
const last_message_id = await click_delete_and_return_last_msg_id(page);
|
||||
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
await page.evaluate(() => {
|
||||
document.querySelector<HTMLButtonElement>(".dialog_submit_button")?.click();
|
||||
});
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
|
||||
await page.waitForSelector(`#${CSS.escape(last_message_id)}`, {hidden: true});
|
||||
assert.equal((await page.$$(".message-list .message_row")).length, messages_quantity - 1);
|
||||
}
|
||||
|
||||
await common.run_test(delete_message_test);
|
||||
329
web/e2e-tests/drafts.test.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {Page} from "puppeteer";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
|
||||
async function wait_for_drafts_to_disappear(page: Page): Promise<void> {
|
||||
await page.waitForSelector("#draft_overlay.show", {hidden: true});
|
||||
}
|
||||
|
||||
async function wait_for_drafts_to_appear(page: Page): Promise<void> {
|
||||
await page.waitForSelector("#draft_overlay.show", {visible: true});
|
||||
}
|
||||
|
||||
async function get_drafts_count(page: Page): Promise<number> {
|
||||
return await page.$$eval("#drafts_table .overlay-message-row", (drafts) => drafts.length);
|
||||
}
|
||||
|
||||
const drafts_button = ".top_left_drafts";
|
||||
const drafts_overlay = "#draft_overlay";
|
||||
|
||||
async function test_empty_drafts(page: Page): Promise<void> {
|
||||
await page.waitForSelector(drafts_button, {visible: true});
|
||||
await page.click(drafts_button);
|
||||
|
||||
await wait_for_drafts_to_appear(page);
|
||||
await page.waitForSelector(drafts_overlay, {visible: true});
|
||||
assert.strictEqual(await common.get_text_from_selector(page, ".drafts-list"), "No drafts.");
|
||||
|
||||
await page.click(`${drafts_overlay} .exit`);
|
||||
await wait_for_drafts_to_disappear(page);
|
||||
}
|
||||
|
||||
async function create_stream_message_draft(page: Page): Promise<void> {
|
||||
console.log("Creating stream message draft");
|
||||
await page.keyboard.press("KeyC");
|
||||
await page.waitForSelector("#stream_message_recipient_topic", {visible: true});
|
||||
await common.select_stream_in_compose_via_dropdown(page, "Denmark");
|
||||
await common.fill_form(page, "form#send_message_form", {
|
||||
content: "Test stream message.",
|
||||
});
|
||||
await page.type("#stream_message_recipient_topic", "tests", {delay: 100});
|
||||
await page.click("#compose_close");
|
||||
}
|
||||
|
||||
async function test_restore_stream_message_draft_by_opening_compose_box(page: Page): Promise<void> {
|
||||
await page.click(".search_icon");
|
||||
await page.waitForSelector("#search_query", {visible: true});
|
||||
await common.clear_and_type(page, "#search_query", "stream:Denmark topic:tests");
|
||||
await page.keyboard.press("Enter");
|
||||
// Wait for narrow to complete.
|
||||
const wait_for_change = true;
|
||||
await common.get_current_msg_list_id(page, wait_for_change);
|
||||
|
||||
await page.waitForSelector("#left_bar_compose_reply_button_big", {visible: true});
|
||||
await page.click("#left_bar_compose_reply_button_big");
|
||||
await page.waitForSelector("#send_message_form", {visible: true});
|
||||
|
||||
await common.check_compose_state(page, {
|
||||
stream_name: "Denmark",
|
||||
topic: "tests",
|
||||
content: "Test stream message. ",
|
||||
});
|
||||
await page.click("#compose_close");
|
||||
await page.waitForSelector("#send_message_form", {visible: false});
|
||||
}
|
||||
|
||||
async function create_private_message_draft(page: Page): Promise<void> {
|
||||
console.log("Creating direct message draft");
|
||||
await page.keyboard.press("KeyX");
|
||||
await page.waitForSelector("#private_message_recipient", {visible: true});
|
||||
await common.fill_form(page, "form#send_message_form", {content: "Test direct message."});
|
||||
await common.pm_recipient.set(page, "cordelia@zulip.com");
|
||||
await common.pm_recipient.set(page, "hamlet@zulip.com");
|
||||
}
|
||||
|
||||
async function test_restore_private_message_draft_by_opening_composebox(page: Page): Promise<void> {
|
||||
await page.waitForSelector("#left_bar_compose_reply_button_big", {visible: true});
|
||||
await page.click("#left_bar_compose_reply_button_big");
|
||||
await page.waitForSelector("#private_message_recipient", {visible: true});
|
||||
|
||||
await common.check_form_contents(page, "form#send_message_form", {
|
||||
content: "Test direct message. ",
|
||||
});
|
||||
await page.click("#compose_close");
|
||||
await page.waitForSelector("#private_message_recipient", {visible: false});
|
||||
}
|
||||
|
||||
async function open_compose_markdown_preview(page: Page): Promise<void> {
|
||||
const new_conversation_button = "#new_conversation_button";
|
||||
await page.waitForSelector(new_conversation_button, {visible: true});
|
||||
await page.click(new_conversation_button);
|
||||
|
||||
const markdown_preview_button = "#compose .markdown_preview"; // eye icon.
|
||||
await page.waitForSelector(markdown_preview_button, {visible: true});
|
||||
await page.click(markdown_preview_button);
|
||||
}
|
||||
|
||||
async function open_drafts_after_markdown_preview(page: Page): Promise<void> {
|
||||
await open_compose_markdown_preview(page);
|
||||
await page.waitForSelector(drafts_button, {visible: true});
|
||||
await page.click(drafts_button);
|
||||
await wait_for_drafts_to_appear(page);
|
||||
}
|
||||
|
||||
async function test_previously_created_drafts_rendered(page: Page): Promise<void> {
|
||||
const drafts_count = await get_drafts_count(page);
|
||||
assert.strictEqual(drafts_count, 2, "Drafts improperly loaded.");
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(
|
||||
page,
|
||||
"#drafts_table .overlay-message-row .message_header_stream .stream_label",
|
||||
),
|
||||
"Denmark",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(
|
||||
page,
|
||||
"#drafts_table .overlay-message-row .message_header_stream .stream_topic",
|
||||
),
|
||||
"tests",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(
|
||||
page,
|
||||
"#drafts_table .overlay-message-row .private-message .rendered_markdown.restore-overlay-message",
|
||||
),
|
||||
"Test direct message.",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(
|
||||
page,
|
||||
"#drafts_table .overlay-message-row .message_header_private_message .stream_label",
|
||||
),
|
||||
"You and Cordelia, Lear's daughter, King Hamlet",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(
|
||||
page,
|
||||
"#drafts_table .overlay-message-row .message_row:not(.private-message) .rendered_markdown.restore-overlay-message",
|
||||
),
|
||||
"Test stream message.",
|
||||
);
|
||||
}
|
||||
|
||||
async function test_restore_message_draft_via_draft_overlay(page: Page): Promise<void> {
|
||||
console.log("Restoring stream message draft");
|
||||
await page.click("#drafts_table .message_row:not(.private-message) .restore-overlay-message");
|
||||
await wait_for_drafts_to_disappear(page);
|
||||
await page.waitForSelector("#stream_message_recipient_topic", {visible: true});
|
||||
await page.waitForSelector("#preview_message_area", {hidden: true});
|
||||
await common.check_compose_state(page, {
|
||||
stream_name: "Denmark",
|
||||
topic: "tests",
|
||||
content: "Test stream message.",
|
||||
});
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, "title"),
|
||||
"#Denmark > tests - Zulip Dev - Zulip",
|
||||
"Didn't narrow to the right topic.",
|
||||
);
|
||||
}
|
||||
|
||||
async function edit_stream_message_draft(page: Page): Promise<void> {
|
||||
await common.select_stream_in_compose_via_dropdown(page, "Denmark");
|
||||
await common.fill_form(page, "form#send_message_form", {
|
||||
content: "Updated stream message",
|
||||
});
|
||||
await page.click("#compose_close");
|
||||
}
|
||||
|
||||
async function test_edited_draft_message(page: Page): Promise<void> {
|
||||
await page.waitForSelector(drafts_button, {visible: true});
|
||||
await page.click(drafts_button);
|
||||
|
||||
await wait_for_drafts_to_appear(page);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(
|
||||
page,
|
||||
"#drafts_table .overlay-message-row .message_header_stream .stream_label",
|
||||
),
|
||||
"Denmark",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(
|
||||
page,
|
||||
"#drafts_table .overlay-message-row .message_header_stream .stream_topic",
|
||||
),
|
||||
"tests",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(
|
||||
page,
|
||||
"#drafts_table .overlay-message-row .message_row:not(.private-message) .rendered_markdown.restore-overlay-message",
|
||||
),
|
||||
"Updated stream message",
|
||||
);
|
||||
}
|
||||
|
||||
async function test_restore_private_message_draft_via_draft_overlay(page: Page): Promise<void> {
|
||||
console.log("Restoring direct message draft.");
|
||||
await page.click(".message_row.private-message .restore-overlay-message");
|
||||
await wait_for_drafts_to_disappear(page);
|
||||
await page.waitForSelector("#compose-direct-recipient", {visible: true});
|
||||
await common.check_compose_state(page, {
|
||||
content: "Test direct message.",
|
||||
});
|
||||
const cordelia_internal_email = await common.get_internal_email_from_name(
|
||||
page,
|
||||
common.fullname.cordelia,
|
||||
);
|
||||
const hamlet_internal_email = await common.get_internal_email_from_name(
|
||||
page,
|
||||
common.fullname.hamlet,
|
||||
);
|
||||
await common.pm_recipient.expect(page, `${cordelia_internal_email},${hamlet_internal_email}`);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, "title"),
|
||||
"Cordelia, Lear's daughter and King Hamlet - Zulip Dev - Zulip",
|
||||
"Didn't narrow to the direct messages with cordelia and hamlet",
|
||||
);
|
||||
await page.click("#compose_close");
|
||||
}
|
||||
|
||||
async function test_delete_draft(page: Page): Promise<void> {
|
||||
console.log("Deleting draft");
|
||||
await page.waitForSelector(drafts_button, {visible: true});
|
||||
await page.click(drafts_button);
|
||||
await wait_for_drafts_to_appear(page);
|
||||
await page.click("#drafts_table .message_row.private-message .delete-overlay-message");
|
||||
const drafts_count = await get_drafts_count(page);
|
||||
assert.strictEqual(drafts_count, 1, "Draft not deleted.");
|
||||
await page.waitForSelector("#drafts_table .message_row.private-message", {hidden: true});
|
||||
await page.click(`${drafts_overlay} .exit`);
|
||||
await wait_for_drafts_to_disappear(page);
|
||||
await page.click("body");
|
||||
}
|
||||
|
||||
async function test_save_draft_by_reloading(page: Page): Promise<void> {
|
||||
console.log("Saving draft by reloading.");
|
||||
await page.keyboard.press("KeyX");
|
||||
await page.waitForSelector("#compose-direct-recipient", {visible: true});
|
||||
await common.fill_form(page, "form#send_message_form", {
|
||||
content: "Test direct message draft.",
|
||||
});
|
||||
await common.pm_recipient.set(page, "cordelia@zulip.com");
|
||||
await page.reload();
|
||||
|
||||
// Reloading into a direct messages narrow opens compose box.
|
||||
await page.waitForSelector("#compose-textarea", {visible: true});
|
||||
await page.click("#compose_close");
|
||||
|
||||
console.log("Reloading finished. Opening drafts again now.");
|
||||
await page.waitForSelector(drafts_button, {visible: true});
|
||||
await page.click(drafts_button);
|
||||
|
||||
console.log("Checking drafts survived after the reload");
|
||||
await wait_for_drafts_to_appear(page);
|
||||
const drafts_count = await get_drafts_count(page);
|
||||
assert.strictEqual(drafts_count, 2, "All drafts aren't loaded.");
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(
|
||||
page,
|
||||
"#drafts_table .overlay-message-row .message_header_private_message .stream_label",
|
||||
),
|
||||
"You and Cordelia, Lear's daughter",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(
|
||||
page,
|
||||
"#drafts_table .overlay-message-row:nth-last-child(2) .rendered_markdown.restore-overlay-message",
|
||||
),
|
||||
"Test direct message draft.",
|
||||
);
|
||||
}
|
||||
|
||||
async function test_delete_draft_on_clearing_text(page: Page): Promise<void> {
|
||||
console.log("Deleting draft by clearing compose box textarea.");
|
||||
await page.click("#drafts_table .message_row:not(.private-message) .restore-overlay-message");
|
||||
await wait_for_drafts_to_disappear(page);
|
||||
await page.waitForSelector("#send_message_form", {visible: true});
|
||||
await common.fill_form(page, "form#send_message_form", {content: ""});
|
||||
await page.click("#compose_close");
|
||||
await page.waitForSelector("#send_message_form", {hidden: true});
|
||||
await page.click(drafts_button);
|
||||
await wait_for_drafts_to_appear(page);
|
||||
const drafts_count = await get_drafts_count(page);
|
||||
assert.strictEqual(drafts_count, 1, "Draft not deleted.");
|
||||
}
|
||||
|
||||
async function drafts_test(page: Page): Promise<void> {
|
||||
await common.log_in(page);
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
const message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(
|
||||
`.message-list[data-message-list-id='${message_list_id}'] .message_row`,
|
||||
{visible: true},
|
||||
);
|
||||
|
||||
await test_empty_drafts(page);
|
||||
|
||||
await create_stream_message_draft(page);
|
||||
await test_restore_stream_message_draft_by_opening_compose_box(page);
|
||||
|
||||
// Send a private message so that the draft we create is
|
||||
// for an existing conversation.
|
||||
await common.send_message(page, "private", {
|
||||
recipient: "cordelia@zulip.com, hamlet@zulip.com",
|
||||
content: "howdy doo",
|
||||
});
|
||||
await create_private_message_draft(page);
|
||||
// Close and try restoring it by opening the composebox again.
|
||||
await page.click("#compose_close");
|
||||
await test_restore_private_message_draft_by_opening_composebox(page);
|
||||
|
||||
await open_drafts_after_markdown_preview(page);
|
||||
await test_previously_created_drafts_rendered(page);
|
||||
|
||||
await test_restore_message_draft_via_draft_overlay(page);
|
||||
await edit_stream_message_draft(page);
|
||||
await test_edited_draft_message(page);
|
||||
|
||||
await test_restore_private_message_draft_via_draft_overlay(page);
|
||||
await test_delete_draft(page);
|
||||
await test_save_draft_by_reloading(page);
|
||||
await test_delete_draft_on_clearing_text(page);
|
||||
}
|
||||
|
||||
await common.run_test(drafts_test);
|
||||
117
web/e2e-tests/edit.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {Page} from "puppeteer";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
|
||||
async function trigger_edit_last_message(page: Page): Promise<void> {
|
||||
const msg = (await page.$$(".message-list .message_row")).at(-1);
|
||||
assert.ok(msg !== undefined);
|
||||
const id = await (await msg.getProperty("id")).jsonValue();
|
||||
await msg.hover();
|
||||
const info = await page.waitForSelector(
|
||||
`#${CSS.escape(id)} .message_control_button.actions_hover`,
|
||||
{visible: true},
|
||||
);
|
||||
assert.ok(info !== null);
|
||||
await info.click();
|
||||
await page.waitForSelector(".popover_edit_message", {visible: true});
|
||||
await page.click(".popover_edit_message");
|
||||
await page.waitForSelector(".message_edit_content", {visible: true});
|
||||
}
|
||||
|
||||
async function edit_stream_message(page: Page, content: string): Promise<void> {
|
||||
await trigger_edit_last_message(page);
|
||||
|
||||
await common.clear_and_type(page, ".message_edit_content", content);
|
||||
await page.click(".message_edit_save");
|
||||
|
||||
await common.wait_for_fully_processed_message(page, content);
|
||||
}
|
||||
|
||||
async function test_stream_message_edit(page: Page): Promise<void> {
|
||||
await common.send_message(page, "stream", {
|
||||
stream_name: "Verona",
|
||||
topic: "edits",
|
||||
content: "test editing",
|
||||
});
|
||||
|
||||
await edit_stream_message(page, "test edited");
|
||||
|
||||
const message_list_id = await common.get_current_msg_list_id(page, false);
|
||||
await common.check_messages_sent(page, message_list_id, [["Verona > edits", ["test edited"]]]);
|
||||
}
|
||||
|
||||
async function test_edit_message_with_slash_me(page: Page): Promise<void> {
|
||||
const last_message_xpath = `(//*[${common.has_class_x("message-list")}]//*[${common.has_class_x(
|
||||
"messagebox",
|
||||
)}])[last()]`;
|
||||
|
||||
await common.send_message(
|
||||
page,
|
||||
"stream",
|
||||
{
|
||||
stream_name: "Verona",
|
||||
topic: "edits",
|
||||
content: "/me test editing a message with me",
|
||||
},
|
||||
// We already narrow in test_stream_message_edit.
|
||||
false,
|
||||
);
|
||||
await page.waitForSelector(
|
||||
`xpath/${last_message_xpath}//*[${common.has_class_x(
|
||||
"status-message",
|
||||
)} and text()="test editing a message with me"]`,
|
||||
);
|
||||
await page.waitForSelector(
|
||||
`xpath/${last_message_xpath}//*[${common.has_class_x(
|
||||
"sender_name",
|
||||
)} and normalize-space()="Desdemona"]`,
|
||||
);
|
||||
|
||||
await edit_stream_message(page, "/me test edited a message with me");
|
||||
|
||||
await page.waitForSelector(
|
||||
`xpath/${last_message_xpath}//*[${common.has_class_x(
|
||||
"status-message",
|
||||
)} and text()="test edited a message with me"]`,
|
||||
);
|
||||
await page.waitForSelector(
|
||||
`xpath/${last_message_xpath}//*[${common.has_class_x(
|
||||
"sender_name",
|
||||
)} and normalize-space()="Desdemona"]`,
|
||||
);
|
||||
}
|
||||
|
||||
async function test_edit_private_message(page: Page): Promise<void> {
|
||||
await common.send_message(page, "private", {
|
||||
recipient: "cordelia@zulip.com",
|
||||
content: "test editing pm",
|
||||
});
|
||||
await trigger_edit_last_message(page);
|
||||
|
||||
await common.clear_and_type(page, ".message_edit_content", "test edited pm");
|
||||
await page.click(".message_edit_save");
|
||||
await common.wait_for_fully_processed_message(page, "test edited pm");
|
||||
|
||||
const message_list_id = await common.get_current_msg_list_id(page, false);
|
||||
await common.check_messages_sent(page, message_list_id, [
|
||||
["You and Cordelia, Lear's daughter", ["test edited pm"]],
|
||||
]);
|
||||
}
|
||||
|
||||
async function edit_tests(page: Page): Promise<void> {
|
||||
await common.log_in(page);
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
const message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(
|
||||
`.message-list[data-message-list-id='${message_list_id}'] .message_row`,
|
||||
{visible: true},
|
||||
);
|
||||
|
||||
await test_stream_message_edit(page);
|
||||
await test_edit_message_with_slash_me(page);
|
||||
await test_edit_private_message(page);
|
||||
}
|
||||
|
||||
await common.run_test(edit_tests);
|
||||
756
web/e2e-tests/lib/common.ts
Normal file
@@ -0,0 +1,756 @@
|
||||
import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import timersPromises from "node:timers/promises";
|
||||
import * as url from "node:url";
|
||||
|
||||
import "css.escape";
|
||||
import ErrorStackParser from "error-stack-parser";
|
||||
import type {Browser, ConsoleMessage, ConsoleMessageLocation, ElementHandle, Page} from "puppeteer";
|
||||
import * as puppeteer from "puppeteer";
|
||||
import StackFrame from "stackframe";
|
||||
import StackTraceGPS from "stacktrace-gps";
|
||||
import * as z from "zod/mini";
|
||||
|
||||
const root_dir = url.fileURLToPath(new URL("../../..", import.meta.url));
|
||||
const puppeteer_dir = path.join(root_dir, "var/puppeteer");
|
||||
|
||||
export const test_credentials = z
|
||||
.object({default_user: z.object({username: z.string(), password: z.string()})})
|
||||
.parse(JSON.parse(fs.readFileSync(path.join(puppeteer_dir, "test_credentials.json"), "utf8")));
|
||||
|
||||
type Message = Record<string, string | boolean> & {
|
||||
recipient?: string;
|
||||
content: string;
|
||||
stream_name?: string;
|
||||
topic?: string;
|
||||
};
|
||||
|
||||
let browser: Browser | null = null;
|
||||
let screenshot_id = 0;
|
||||
export const is_firefox = process.env["PUPPETEER_PRODUCT"] === "firefox";
|
||||
let realm_url = "http://zulip.zulipdev.com:9981/";
|
||||
const gps = new StackTraceGPS({ajax: async (url) => (await fetch(url)).text()});
|
||||
|
||||
let last_current_msg_list_id: number | undefined;
|
||||
|
||||
export const pm_recipient = {
|
||||
async set(page: Page, recipient: string): Promise<void> {
|
||||
// Without using the delay option here there seems to be
|
||||
// a flake where the typeahead doesn't show up.
|
||||
// The flake seems to be due to some method that triggers focus on
|
||||
// compose textarea, which causes the typeahead to not show up.
|
||||
// Add a delay before typing.
|
||||
await timersPromises.setTimeout(100);
|
||||
await page.type("#private_message_recipient", recipient);
|
||||
|
||||
// PM typeaheads always have an image. This ensures we are waiting for the right typeahead to appear.
|
||||
const entry = await page.waitForSelector(".typeahead .active a .typeahead-image", {
|
||||
visible: false,
|
||||
});
|
||||
// log entry in puppeteer logs
|
||||
console.log(await entry!.evaluate((el) => el.textContent));
|
||||
await entry!.click();
|
||||
},
|
||||
|
||||
async expect(page: Page, expected: string): Promise<void> {
|
||||
const actual_recipients = await page.evaluate(() =>
|
||||
zulip_test.private_message_recipient_emails(),
|
||||
);
|
||||
assert.equal(actual_recipients, expected);
|
||||
},
|
||||
};
|
||||
|
||||
export const fullname = {
|
||||
cordelia: "Cordelia, Lear's daughter",
|
||||
othello: "Othello, the Moor of Venice",
|
||||
hamlet: "King Hamlet",
|
||||
};
|
||||
|
||||
export const window_size = {
|
||||
width: 1400,
|
||||
height: 1024,
|
||||
};
|
||||
|
||||
export async function ensure_browser(): Promise<Browser> {
|
||||
browser ??= await puppeteer.launch({
|
||||
args: [
|
||||
`--window-size=${window_size.width},${window_size.height}`,
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
],
|
||||
// TODO: Change defaultViewport to 1280x1024 when puppeteer fixes the window size issue with firefox.
|
||||
// Here is link to the issue that is tracking the above problem https://github.com/puppeteer/puppeteer/issues/6442.
|
||||
defaultViewport: null,
|
||||
headless: true,
|
||||
});
|
||||
return browser;
|
||||
}
|
||||
|
||||
export async function get_page(): Promise<Page> {
|
||||
const browser = await ensure_browser();
|
||||
const page = await browser.newPage();
|
||||
return page;
|
||||
}
|
||||
|
||||
export async function screenshot(page: Page, name: string | null = null): Promise<void> {
|
||||
if (name === null) {
|
||||
name = `${screenshot_id}`;
|
||||
screenshot_id += 1;
|
||||
}
|
||||
|
||||
await page.screenshot({
|
||||
path: `${path.join(puppeteer_dir, name)}.png`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function page_url_with_fragment(page: Page): Promise<string> {
|
||||
// `page.url()` does not include the url fragment when running
|
||||
// Puppeteer with Firefox: https://github.com/puppeteer/puppeteer/issues/6787.
|
||||
//
|
||||
// This function hacks around that issue; once it's fixed in
|
||||
// puppeteer upstream, we can delete this function and return
|
||||
// its callers to using `page.url()`
|
||||
return await page.evaluate(() => window.location.href);
|
||||
}
|
||||
|
||||
// This function will clear the existing value of the element and
|
||||
// replace it with the text.
|
||||
export async function clear_and_type(page: Page, selector: string, text: string): Promise<void> {
|
||||
// Select all text currently in the element.
|
||||
await page.click(selector, {count: 3});
|
||||
await page.keyboard.press("Delete");
|
||||
await page.type(selector, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function takes a params object whose fields
|
||||
* are referenced by name attribute of an input field and
|
||||
* the input as a key.
|
||||
*
|
||||
* For example to fill:
|
||||
* <form id="#demo">
|
||||
* <input type="text" name="username">
|
||||
* <input type="checkbox" name="terms">
|
||||
* </form>
|
||||
*
|
||||
* You can call:
|
||||
* common.fill_form(page, '#demo', {
|
||||
* username: 'Iago',
|
||||
* terms: true
|
||||
* });
|
||||
*/
|
||||
export async function fill_form(
|
||||
page: Page,
|
||||
form_selector: string,
|
||||
params: Record<string, boolean | string>,
|
||||
): Promise<void> {
|
||||
async function is_dropdown(page: Page, name: string): Promise<boolean> {
|
||||
return (await page.$(`select[name="${CSS.escape(name)}"]`)) !== null;
|
||||
}
|
||||
for (const [name, value] of Object.entries(params)) {
|
||||
if (typeof value === "boolean") {
|
||||
await page.$eval(
|
||||
`${form_selector} input[name="${CSS.escape(name)}"]`,
|
||||
(el, value) => {
|
||||
if (el.checked !== value) {
|
||||
el.click();
|
||||
}
|
||||
},
|
||||
value,
|
||||
);
|
||||
} else if (await is_dropdown(page, name)) {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError(`Expected string for ${name}`);
|
||||
}
|
||||
await page.select(`${form_selector} select[name="${CSS.escape(name)}"]`, value);
|
||||
} else {
|
||||
await clear_and_type(page, `${form_selector} [name="${CSS.escape(name)}"]`, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function check_form_contents(
|
||||
page: Page,
|
||||
form_selector: string,
|
||||
params: Record<string, boolean | string>,
|
||||
): Promise<void> {
|
||||
for (const [name, expected_value] of Object.entries(params)) {
|
||||
if (typeof expected_value === "boolean") {
|
||||
assert.equal(
|
||||
await page.$eval(
|
||||
`${form_selector} input[name="${CSS.escape(name)}"]`,
|
||||
(el) => el.checked,
|
||||
),
|
||||
expected_value,
|
||||
"Form content is not as expected.",
|
||||
);
|
||||
} else {
|
||||
assert.equal(
|
||||
await page.$eval(`${form_selector} [name="${CSS.escape(name)}"]`, (el) => {
|
||||
if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) {
|
||||
throw new TypeError("Expected <input> or <textarea>");
|
||||
}
|
||||
return el.value;
|
||||
}),
|
||||
expected_value,
|
||||
"Form content is not as expected.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function get_element_text(element: ElementHandle): Promise<string> {
|
||||
const text = await (await element.getProperty("innerText")).jsonValue();
|
||||
assert.ok(typeof text === "string");
|
||||
return text;
|
||||
}
|
||||
|
||||
export async function get_text_from_selector(page: Page, selector: string): Promise<string> {
|
||||
const elements = await page.$$(selector);
|
||||
const texts = await Promise.all(elements.map(async (element) => get_element_text(element)));
|
||||
return texts.join("").trim();
|
||||
}
|
||||
|
||||
export async function check_compose_state(
|
||||
page: Page,
|
||||
params: {stream_name?: string; topic?: string; content: string},
|
||||
): Promise<void> {
|
||||
const form_params: Record<string, string> = {content: params.content};
|
||||
if (params.stream_name) {
|
||||
assert.equal(
|
||||
await get_text_from_selector(
|
||||
page,
|
||||
"#compose_select_recipient_widget .dropdown_widget_value",
|
||||
),
|
||||
params.stream_name,
|
||||
);
|
||||
}
|
||||
if (params.topic) {
|
||||
form_params["stream_message_recipient_topic"] = params.topic;
|
||||
}
|
||||
await check_form_contents(page, "form#send_message_form", form_params);
|
||||
}
|
||||
|
||||
export function has_class_x(class_name: string): string {
|
||||
return `contains(concat(" ", @class, " "), " ${class_name} ")`;
|
||||
}
|
||||
|
||||
export async function get_stream_id(page: Page, stream_name: string): Promise<number | undefined> {
|
||||
return await page.evaluate(
|
||||
(stream_name: string) => zulip_test.get_stream_id(stream_name),
|
||||
stream_name,
|
||||
);
|
||||
}
|
||||
|
||||
export async function get_user_id_from_name(page: Page, name: string): Promise<number | undefined> {
|
||||
return await page.evaluate((name: string) => zulip_test.get_user_id_from_name(name), name);
|
||||
}
|
||||
|
||||
export async function get_internal_email_from_name(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<string | undefined> {
|
||||
return await page.evaluate((fullname: string) => {
|
||||
const user_id = zulip_test.get_user_id_from_name(fullname);
|
||||
return user_id === undefined ? undefined : zulip_test.get_person_by_user_id(user_id).email;
|
||||
}, name);
|
||||
}
|
||||
|
||||
export async function log_in(
|
||||
page: Page,
|
||||
credentials: {username: string; password: string} = test_credentials.default_user,
|
||||
): Promise<void> {
|
||||
console.log("Logging in");
|
||||
await page.goto(realm_url + "login/");
|
||||
assert.equal(realm_url + "login/", page.url());
|
||||
// fill login form
|
||||
const params = {
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
};
|
||||
await fill_form(page, "form#login_form", params);
|
||||
await page.$eval("form#login_form", (form) => {
|
||||
form.submit();
|
||||
});
|
||||
|
||||
await page.waitForSelector("#inbox-main", {visible: true});
|
||||
}
|
||||
|
||||
export async function log_out(page: Page): Promise<void> {
|
||||
await page.goto(realm_url);
|
||||
const menu_selector = "#personal-menu";
|
||||
const logout_selector = ".personal-menu-actions a.logout_button";
|
||||
console.log("Logging out");
|
||||
await page.waitForSelector(menu_selector, {visible: true});
|
||||
await page.click(menu_selector);
|
||||
await page.waitForSelector(logout_selector);
|
||||
await Promise.all([page.waitForNavigation(), page.click(logout_selector)]);
|
||||
|
||||
// Wait for a email input in login page so we know login
|
||||
// page is loaded. Then check that we are at the login url.
|
||||
await page.waitForSelector('input[name="username"]');
|
||||
assert.ok(page.url().includes("/login/"));
|
||||
}
|
||||
|
||||
export function set_realm_url(new_realm_url: string): void {
|
||||
realm_url = new_realm_url;
|
||||
}
|
||||
|
||||
export async function ensure_enter_does_not_send(page: Page): Promise<void> {
|
||||
// NOTE: Caller should ensure that the compose box is already open.
|
||||
await page.click("#send_later");
|
||||
await page.waitForSelector("#send_later_popover");
|
||||
const enter_sends = await page.$eval(
|
||||
".enter_sends_choice input[value='true']",
|
||||
(el) => el.checked,
|
||||
);
|
||||
|
||||
if (enter_sends) {
|
||||
const enter_sends_false_selector = ".enter_sends_choice input[value='false']";
|
||||
await page.waitForSelector(enter_sends_false_selector);
|
||||
await page.click(enter_sends_false_selector);
|
||||
}
|
||||
}
|
||||
|
||||
export async function assert_compose_box_content(
|
||||
page: Page,
|
||||
expected_value: string,
|
||||
): Promise<void> {
|
||||
const compose_box_element = await page.waitForSelector("textarea#compose-textarea");
|
||||
assert.ok(compose_box_element !== null);
|
||||
const compose_box_content = await page.evaluate(
|
||||
(element) => element.value,
|
||||
compose_box_element,
|
||||
);
|
||||
assert.equal(
|
||||
compose_box_content,
|
||||
expected_value,
|
||||
`Compose box content did not match with the expected value '{${expected_value}}'`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function wait_for_fully_processed_message(page: Page, content: string): Promise<void> {
|
||||
// Wait in parallel for the message list scroll animation, which
|
||||
// interferes with Puppeteer accurately clicking on messages.
|
||||
const scroll_delay = timersPromises.setTimeout(400);
|
||||
|
||||
await page.waitForFunction(
|
||||
(content: string) => {
|
||||
/*
|
||||
The tricky part about making sure that
|
||||
a message has actually been fully processed
|
||||
is that we'll "locally echo" the message
|
||||
first on the client. Until the server
|
||||
actually acks the message, the message will
|
||||
have a temporary id and will not have all
|
||||
the normal message controls.
|
||||
For the Puppeteer tests, we want to avoid all
|
||||
the edge cases with locally echoed messages.
|
||||
In order to make sure a message is processed,
|
||||
we use internals to determine the following:
|
||||
- has message_list even been updated with
|
||||
the message with out content?
|
||||
- has the locally_echoed flag been cleared?
|
||||
But for the final steps we look at the
|
||||
actual DOM (via JQuery):
|
||||
- is it visible?
|
||||
- does it look to have been
|
||||
re-rendered based on server info?
|
||||
*/
|
||||
const last_msg = zulip_test.current_msg_list?.last();
|
||||
if (last_msg === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (last_msg.raw_content !== content) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (last_msg.locally_echoed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const $row = zulip_test.last_visible_row();
|
||||
if (zulip_test.row_id($row) !== last_msg.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
Make sure the message is completely
|
||||
re-rendered from its original "local echo"
|
||||
version by looking for the star icon. We
|
||||
don't add the star icon until the server
|
||||
responds.
|
||||
*/
|
||||
return $row.find(".star").length === 1;
|
||||
},
|
||||
{},
|
||||
content,
|
||||
);
|
||||
|
||||
await scroll_delay;
|
||||
}
|
||||
|
||||
export async function select_stream_in_compose_via_dropdown(
|
||||
page: Page,
|
||||
stream_name: string,
|
||||
): Promise<void> {
|
||||
console.log(`Clicking on 'compose_select_recipient_widget' to select ${stream_name}`);
|
||||
const menu_visible = (await page.$(".dropdown-list-container")) !== null;
|
||||
if (!menu_visible) {
|
||||
await page.waitForSelector("#compose_select_recipient_widget", {visible: true});
|
||||
await page.click("#compose_select_recipient_widget");
|
||||
await page.waitForSelector(".dropdown-list-container .list-item", {
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
const stream_to_select = `.dropdown-list-container .list-item[data-name="${stream_name}"]`;
|
||||
await page.waitForSelector(stream_to_select, {visible: true});
|
||||
await page.click(stream_to_select);
|
||||
await page.waitForSelector(".dropdown-list-container", {hidden: true});
|
||||
}
|
||||
|
||||
// Wait for any previous send to finish, then send a message.
|
||||
export async function send_message(
|
||||
page: Page,
|
||||
type: "stream" | "private",
|
||||
params: Message,
|
||||
wait_for_narrow_change = true,
|
||||
): Promise<void> {
|
||||
// Compose box content should be empty before sending the message.
|
||||
await assert_compose_box_content(page, "");
|
||||
|
||||
if (type === "stream") {
|
||||
await page.keyboard.press("KeyC");
|
||||
} else if (type === "private") {
|
||||
await page.keyboard.press("KeyX");
|
||||
const recipients = params.recipient!.split(", ");
|
||||
for (const recipient of recipients) {
|
||||
await pm_recipient.set(page, recipient);
|
||||
}
|
||||
delete params.recipient;
|
||||
} else {
|
||||
assert.fail("`send_message` got invalid message type");
|
||||
}
|
||||
|
||||
if (params.stream_name) {
|
||||
await select_stream_in_compose_via_dropdown(page, params.stream_name);
|
||||
delete params.stream_name;
|
||||
}
|
||||
|
||||
if (params.topic) {
|
||||
params["stream_message_recipient_topic"] = params.topic;
|
||||
delete params.topic;
|
||||
}
|
||||
|
||||
await fill_form(page, 'form[action^="/json/messages"]', params);
|
||||
await assert_compose_box_content(page, params.content);
|
||||
await ensure_enter_does_not_send(page);
|
||||
await page.waitForSelector("#compose-send-button", {visible: true});
|
||||
await page.click("#compose-send-button");
|
||||
|
||||
if (wait_for_narrow_change) {
|
||||
// After the message is sent, wait for the narrow
|
||||
// to change to the message recipient.
|
||||
await get_current_msg_list_id(page, true);
|
||||
}
|
||||
|
||||
// Sending should clear compose box content.
|
||||
await assert_compose_box_content(page, "");
|
||||
await wait_for_fully_processed_message(page, params.content);
|
||||
|
||||
// Close the compose box after sending the message.
|
||||
await page.evaluate(() => {
|
||||
zulip_test.cancel_compose();
|
||||
});
|
||||
// Make sure the compose box is closed.
|
||||
await page.waitForSelector("#compose-textarea", {hidden: true});
|
||||
}
|
||||
|
||||
export async function send_multiple_messages(page: Page, msgs: Message[]): Promise<void> {
|
||||
let last_msg_stream;
|
||||
let last_msg_topic;
|
||||
let last_msg_recipient;
|
||||
for (const msg of msgs) {
|
||||
const msg_type = msg.stream_name !== undefined ? "stream" : "private";
|
||||
// Check if `msg` and `last_msg` are in the same narrow.
|
||||
let wait_for_narrow_change = true;
|
||||
if (
|
||||
msg.stream_name === last_msg_stream &&
|
||||
msg.topic === last_msg_topic &&
|
||||
msg.recipient === last_msg_recipient
|
||||
) {
|
||||
wait_for_narrow_change = false;
|
||||
}
|
||||
last_msg_stream = msg.stream_name;
|
||||
last_msg_topic = msg.topic;
|
||||
last_msg_recipient = msg.recipient;
|
||||
await send_message(page, msg_type, msg, wait_for_narrow_change);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method returns a array, which is formatted as:
|
||||
* [
|
||||
* ['stream > topic', ['message 1', 'message 2']],
|
||||
* ['You and Cordelia, Lear's daughter', ['message 1', 'message 2']]
|
||||
* ]
|
||||
*
|
||||
* The messages are sorted chronologically.
|
||||
*/
|
||||
export async function get_rendered_messages(
|
||||
page: Page,
|
||||
message_list_id: number,
|
||||
): Promise<[string, string[]][]> {
|
||||
const recipient_rows = await page.$$(
|
||||
`.message-list[data-message-list-id='${message_list_id}'] .recipient_row`,
|
||||
);
|
||||
return Promise.all(
|
||||
recipient_rows.map(async (element): Promise<[string, string[]]> => {
|
||||
const stream_label = await element.$(".stream_label");
|
||||
const stream_name = (await get_element_text(stream_label!)).trim();
|
||||
const topic_label = await element.$(".stream_topic a");
|
||||
const topic_name =
|
||||
topic_label === null ? "" : (await get_element_text(topic_label)).trim();
|
||||
let key = stream_name;
|
||||
if (topic_name !== "") {
|
||||
// If topic_name is '', then this is direct messages, so only
|
||||
// append > topic_name if we are not in 1:1 or group direct
|
||||
// messages.
|
||||
key = `${stream_name} > ${topic_name}`;
|
||||
}
|
||||
|
||||
const messages = await Promise.all(
|
||||
(await element.$$(".message_row .message_content")).map(async (message_row) =>
|
||||
(await get_element_text(message_row)).trim(),
|
||||
),
|
||||
);
|
||||
|
||||
return [key, messages];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// This method takes in page, table to fetch the messages
|
||||
// from, and expected messages. The format of expected
|
||||
// message is { "stream > topic": [messages] }.
|
||||
// The method will only check that all the messages in the
|
||||
// messages array passed exist in the order they are passed.
|
||||
export async function check_messages_sent(
|
||||
page: Page,
|
||||
message_list_id: number,
|
||||
messages: [string, string[]][],
|
||||
): Promise<void> {
|
||||
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
|
||||
visible: true,
|
||||
});
|
||||
const rendered_messages = await get_rendered_messages(page, message_list_id);
|
||||
|
||||
// We only check the last n messages because if we run
|
||||
// the test with --interactive there will be duplicates.
|
||||
const last_n_messages = rendered_messages.slice(-messages.length);
|
||||
assert.deepStrictEqual(last_n_messages, messages);
|
||||
}
|
||||
|
||||
export async function open_streams_modal(page: Page): Promise<void> {
|
||||
const all_streams_selector = "#subscribe-to-more-streams";
|
||||
await page.waitForSelector(all_streams_selector, {visible: true});
|
||||
await page.click(all_streams_selector);
|
||||
|
||||
await page.waitForSelector("#subscription_overlay", {visible: true});
|
||||
const url = await page_url_with_fragment(page);
|
||||
assert.ok(url.includes("#channels/available"));
|
||||
}
|
||||
|
||||
export async function open_personal_menu(page: Page): Promise<void> {
|
||||
const menu_selector = "#personal-menu";
|
||||
await page.waitForSelector(menu_selector, {visible: true});
|
||||
await page.click(menu_selector);
|
||||
}
|
||||
|
||||
export async function manage_organization(page: Page): Promise<void> {
|
||||
const menu_selector = "#settings-dropdown";
|
||||
await page.waitForSelector(menu_selector, {visible: true});
|
||||
await page.click(menu_selector);
|
||||
|
||||
const organization_settings = '.link-item a[href="#organization"]';
|
||||
await page.waitForSelector(organization_settings, {visible: true});
|
||||
await page.click(organization_settings);
|
||||
await page.waitForSelector("#settings_overlay_container.show", {visible: true});
|
||||
|
||||
const url = await page_url_with_fragment(page);
|
||||
assert.match(url, /^http:\/\/[^/]+\/#organization/, "Unexpected organization settings URL");
|
||||
|
||||
const organization_settings_data_section = "li[data-section='organization-settings']";
|
||||
await page.click(organization_settings_data_section);
|
||||
}
|
||||
|
||||
export async function select_item_via_typeahead(
|
||||
page: Page,
|
||||
field_selector: string,
|
||||
str: string,
|
||||
item: string,
|
||||
): Promise<void> {
|
||||
console.log(`Looking in ${field_selector} to select ${str}, ${item}`);
|
||||
await clear_and_type(page, field_selector, str);
|
||||
const entry = await page.waitForSelector(
|
||||
`xpath///*[${has_class_x("typeahead")}]//li[contains(normalize-space(), "${item}")]//a`,
|
||||
{visible: true},
|
||||
);
|
||||
assert.ok(entry);
|
||||
await entry.hover();
|
||||
await entry.click();
|
||||
}
|
||||
|
||||
export async function wait_for_modal_to_close(page: Page): Promise<void> {
|
||||
// This function will ensure that the mouse events are enabled for the background for further tests.
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector(".overlay.show")?.getAttribute("style") === null,
|
||||
);
|
||||
}
|
||||
|
||||
export async function wait_for_micromodal_to_open(page: Page): Promise<void> {
|
||||
// We manually add the `modal--open` class to the modal after the modal animation completes.
|
||||
await page.waitForFunction(() => document.querySelector(".modal--open") !== null);
|
||||
}
|
||||
|
||||
export async function wait_for_micromodal_to_close(page: Page): Promise<void> {
|
||||
// This function will ensure that the mouse events are enabled for the background for further tests.
|
||||
await page.waitForFunction(() => document.querySelector(".modal--open") === null);
|
||||
}
|
||||
|
||||
export async function run_test(test_function: (page: Page) => Promise<void>): Promise<void> {
|
||||
// Pass a page instance to test so we can take
|
||||
// a screenshot of it when the test fails.
|
||||
const browser = await ensure_browser();
|
||||
const page = await get_page();
|
||||
|
||||
// Used to keep console messages in order after async source mapping
|
||||
let console_ready = Promise.resolve();
|
||||
|
||||
page.on("console", (message: ConsoleMessage) => {
|
||||
const context = async ({
|
||||
url,
|
||||
lineNumber,
|
||||
columnNumber,
|
||||
}: ConsoleMessageLocation): Promise<string> => {
|
||||
let frame = new StackFrame({
|
||||
...(url !== undefined && {fileName: url}),
|
||||
...(lineNumber !== undefined && {lineNumber: lineNumber + 1}),
|
||||
...(columnNumber !== undefined && {columnNumber: columnNumber + 1}),
|
||||
});
|
||||
try {
|
||||
frame = await gps.getMappedLocation(frame);
|
||||
} catch {
|
||||
// Ignore source mapping errors
|
||||
}
|
||||
if (frame.lineNumber === undefined || frame.columnNumber === undefined) {
|
||||
return String(frame.fileName);
|
||||
}
|
||||
return `${String(frame.fileName)}:${frame.lineNumber}:${frame.columnNumber}`;
|
||||
};
|
||||
|
||||
const console_ready1 = console_ready;
|
||||
console_ready = (async () => {
|
||||
let output = `${await context(
|
||||
message.location(),
|
||||
)}: ${message.type()}: ${message.text()}`;
|
||||
if (message.type() === "trace") {
|
||||
for (const frame of message.stackTrace()) {
|
||||
output += `\n at ${await context(frame)}`;
|
||||
}
|
||||
}
|
||||
await console_ready1;
|
||||
console.log(output);
|
||||
})();
|
||||
});
|
||||
|
||||
let page_errored = false;
|
||||
page.on("pageerror", (error: unknown) => {
|
||||
page_errored = true;
|
||||
|
||||
const console_ready1 = console_ready;
|
||||
console_ready = (async () => {
|
||||
let message;
|
||||
if (error instanceof Error) {
|
||||
const frames = await Promise.all(
|
||||
ErrorStackParser.parse(error).map(async (frame) => {
|
||||
try {
|
||||
frame = await gps.getMappedLocation(frame);
|
||||
} catch {
|
||||
// Ignore source mapping errors
|
||||
}
|
||||
return `\n at ${String(frame.functionName)} (${String(
|
||||
frame.fileName,
|
||||
)}:${String(frame.lineNumber)}:${String(frame.columnNumber)})`;
|
||||
}),
|
||||
);
|
||||
message = error.toString() + frames.join("");
|
||||
} else {
|
||||
message = String(error);
|
||||
}
|
||||
await console_ready1;
|
||||
console.error("Page error:", message);
|
||||
})();
|
||||
|
||||
const console_ready2 = console_ready;
|
||||
console_ready = (async () => {
|
||||
try {
|
||||
// Take a screenshot, and increment the screenshot_id.
|
||||
await screenshot(page, `failure-${screenshot_id}`);
|
||||
screenshot_id += 1;
|
||||
} finally {
|
||||
await console_ready2;
|
||||
console.log("Closing page to stop the test...");
|
||||
await page.close();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
try {
|
||||
await test_function(page);
|
||||
await log_out(page);
|
||||
|
||||
if (page_errored) {
|
||||
throw new Error("Page threw an error");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!page_errored) {
|
||||
// Take a screenshot, and increment the screenshot_id.
|
||||
await screenshot(page, `failure-${screenshot_id}`);
|
||||
screenshot_id += 1;
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
await console_ready;
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function get_current_msg_list_id(
|
||||
page: Page,
|
||||
wait_for_change = false,
|
||||
): Promise<number> {
|
||||
if (wait_for_change) {
|
||||
// Wait for the current_msg_list to change if the in the middle of switching narrows.
|
||||
// Also works as a way to verify that the current message list did change.
|
||||
// NOTE: This only checks if the current message list id changed from the last call to this function,
|
||||
// so, make sure to have a call to this function before changing to the narrow that you want to check.
|
||||
await page.waitForFunction(
|
||||
(last_current_msg_list_id) => {
|
||||
const current_msg_list = zulip_test.current_msg_list;
|
||||
return (
|
||||
current_msg_list !== undefined &&
|
||||
current_msg_list.id !== last_current_msg_list_id
|
||||
);
|
||||
},
|
||||
{},
|
||||
last_current_msg_list_id,
|
||||
);
|
||||
}
|
||||
last_current_msg_list_id = await page.evaluate(() => zulip_test.current_msg_list?.id);
|
||||
assert.ok(last_current_msg_list_id !== undefined);
|
||||
return last_current_msg_list_id;
|
||||
}
|
||||
268
web/e2e-tests/mark-messages-read-near.test.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {Page} from "puppeteer";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
|
||||
const stream_name = "Verona";
|
||||
const topic_name = "near-read-test";
|
||||
const realm_url = "http://zulip.zulipdev.com:9981/";
|
||||
|
||||
async function get_stream_id(page: Page): Promise<number> {
|
||||
const stream_id = await common.get_stream_id(page, stream_name);
|
||||
assert.ok(stream_id !== undefined);
|
||||
return stream_id;
|
||||
}
|
||||
|
||||
async function navigate_to_settings_preferences(page: Page): Promise<void> {
|
||||
await common.open_personal_menu(page);
|
||||
await page.waitForSelector("#personal-menu-dropdown a[href^='#settings']", {visible: true});
|
||||
await page.click("#personal-menu-dropdown a[href^='#settings']");
|
||||
await page.waitForSelector("#settings_overlay_container.show", {visible: true});
|
||||
await page.waitForSelector('[data-section="preferences"]', {visible: true});
|
||||
await page.click('[data-section="preferences"]');
|
||||
await page.waitForSelector("#user_web_mark_read_on_scroll_policy", {visible: true});
|
||||
}
|
||||
|
||||
async function change_mark_read_policy(page: Page, value: string): Promise<void> {
|
||||
await navigate_to_settings_preferences(page);
|
||||
await page.select("#user_web_mark_read_on_scroll_policy", value);
|
||||
await page.waitForSelector("#user-preferences .general-settings-status", {visible: true});
|
||||
await page.click("#settings_page .content-wrapper .exit");
|
||||
await page.waitForSelector("#settings_overlay_container", {hidden: true});
|
||||
}
|
||||
|
||||
// Test 1: A /near/ narrow is treated as a conversation view.
|
||||
async function test_near_narrow_is_conversation_view(page: Page): Promise<void> {
|
||||
console.log("Testing that near narrow is treated as a conversation view");
|
||||
|
||||
await common.send_message(page, "stream", {
|
||||
stream_name,
|
||||
topic: topic_name,
|
||||
content: "message for near narrow test",
|
||||
});
|
||||
|
||||
const stream_id = await get_stream_id(page);
|
||||
const msg_id = await page.evaluate(() => zulip_test.current_msg_list?.last()?.id);
|
||||
assert.ok(msg_id !== undefined, "Expected a message to be sent and visible");
|
||||
|
||||
// Navigate away from the topic, then come back via /near/ URL.
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
await common.get_current_msg_list_id(page, true);
|
||||
|
||||
await page.goto(
|
||||
`${realm_url}#narrow/channel/${stream_id}-${stream_name}/topic/${topic_name}/near/${msg_id}`,
|
||||
);
|
||||
await common.get_current_msg_list_id(page, true);
|
||||
|
||||
const is_conversation_view = await page.evaluate(() =>
|
||||
zulip_test.current_msg_list?.data.filter.is_conversation_view(),
|
||||
);
|
||||
assert.ok(is_conversation_view, "Near narrow should be treated as a conversation view");
|
||||
|
||||
const is_conversation_view_with_near = await page.evaluate(() =>
|
||||
zulip_test.current_msg_list?.data.filter.is_conversation_view_with_near(),
|
||||
);
|
||||
assert.ok(
|
||||
is_conversation_view_with_near,
|
||||
"Near narrow should still identify as is_conversation_view_with_near",
|
||||
);
|
||||
}
|
||||
|
||||
// Test 2: The reading gate clears immediately when all messages in the
|
||||
// near narrow are already read, enabling normal mark-as-read behavior.
|
||||
async function test_near_narrow_reading_gate_clears(page: Page): Promise<void> {
|
||||
console.log("Testing that reading gate clears when no unread messages in near view");
|
||||
|
||||
// We are on the near narrow from test 1; get the last message ID.
|
||||
const stream_id = await get_stream_id(page);
|
||||
const msg_id = await page.evaluate(() => zulip_test.current_msg_list?.last()?.id);
|
||||
assert.ok(msg_id !== undefined);
|
||||
|
||||
// Navigate away, then return via /near/ URL. All messages were
|
||||
// already marked as read in test 1 when we first visited this topic.
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
await common.get_current_msg_list_id(page, true);
|
||||
|
||||
await page.goto(
|
||||
`${realm_url}#narrow/channel/${stream_id}-${stream_name}/topic/${topic_name}/near/${msg_id}`,
|
||||
);
|
||||
await common.get_current_msg_list_id(page, true);
|
||||
|
||||
// The not_found case fires immediately (no unreads), clearing the gate
|
||||
// and resuming reading via resume_reading().
|
||||
await page.waitForFunction(
|
||||
() => zulip_test.current_msg_list?.near_view_reading_gate_pending === false,
|
||||
{timeout: 5000},
|
||||
);
|
||||
const reading_prevented = await page.evaluate(
|
||||
() => zulip_test.current_msg_list?.reading_prevented,
|
||||
);
|
||||
assert.equal(
|
||||
reading_prevented,
|
||||
false,
|
||||
"reading_prevented should be false after gate clears with no unreads",
|
||||
);
|
||||
}
|
||||
|
||||
// Test 3: With conversation_only policy, near views don't show the
|
||||
// "not a conversation view" banner because they're treated as conversation views.
|
||||
async function test_near_narrow_no_conversation_only_banner(page: Page): Promise<void> {
|
||||
console.log("Testing banner behavior in near narrow with conversation_only policy");
|
||||
|
||||
// We are on the near narrow from test 2.
|
||||
const stream_id = await get_stream_id(page);
|
||||
const msg_id = await page.evaluate(() => zulip_test.current_msg_list?.last()?.id);
|
||||
assert.ok(msg_id !== undefined);
|
||||
|
||||
// Change policy to "Only in conversation views".
|
||||
await change_mark_read_policy(page, "2");
|
||||
|
||||
// Navigate to a channel-only narrow (not a conversation view).
|
||||
// update_unread_banner() always sets the banner HTML on narrow change, so
|
||||
// verify the banner text contains "conversation" without requiring the banner
|
||||
// to be visible (which requires an unread message + scroll to trigger).
|
||||
await page.goto(`${realm_url}#narrow/channel/${stream_id}-${stream_name}`);
|
||||
await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(
|
||||
document.querySelector("#mark_as_read_turned_off_content")?.textContent ?? ""
|
||||
).includes("conversation"),
|
||||
{timeout: 5000},
|
||||
);
|
||||
const channel_banner_text = await page.$eval(
|
||||
"#mark_as_read_turned_off_content",
|
||||
(el) => el.textContent ?? "",
|
||||
);
|
||||
assert.ok(
|
||||
channel_banner_text.includes("conversation"),
|
||||
"Channel narrow should use 'not a conversation view' banner template",
|
||||
);
|
||||
|
||||
// Navigate to a near narrow. It should not show the "not a
|
||||
// conversation view" banner since near views are now conversation views.
|
||||
await page.goto(
|
||||
`${realm_url}#narrow/channel/${stream_id}-${stream_name}/topic/${topic_name}/near/${msg_id}`,
|
||||
);
|
||||
await common.get_current_msg_list_id(page, true);
|
||||
|
||||
// Wait for the reading gate to clear (all messages are read, so
|
||||
// maybe_resume_reading_for_near_view fires the not_found path).
|
||||
await page.waitForFunction(
|
||||
() => zulip_test.current_msg_list?.near_view_reading_gate_pending === false,
|
||||
{timeout: 5000},
|
||||
);
|
||||
|
||||
const near_banner_text = await page.$eval(
|
||||
"#mark_as_read_turned_off_content",
|
||||
(el) => el.textContent ?? "",
|
||||
);
|
||||
assert.ok(
|
||||
!near_banner_text.includes("conversation"),
|
||||
"Near narrow should not show 'not a conversation view' banner",
|
||||
);
|
||||
|
||||
// Reset the policy to "Always".
|
||||
await change_mark_read_policy(page, "1");
|
||||
}
|
||||
|
||||
// Test 4: The reading gate only clears once the first unread message
|
||||
// has scrolled into the visible area of the viewport.
|
||||
async function test_near_narrow_gate_requires_scroll(page: Page): Promise<void> {
|
||||
console.log("Testing that gate requires first unread to be visible before marking as read");
|
||||
|
||||
const gate_topic = "near-gate-test";
|
||||
const stream_id = await get_stream_id(page);
|
||||
|
||||
// Send 15 messages so that, when anchored at the last one, the first
|
||||
// message is well above the viewport and the gate cannot clear until
|
||||
// the user scrolls back to the top.
|
||||
const msgs = Array.from({length: 15}, (_, i) => ({
|
||||
stream_name,
|
||||
topic: gate_topic,
|
||||
content: `gate test message ${i + 1}`,
|
||||
}));
|
||||
await common.send_multiple_messages(page, msgs);
|
||||
const msg_last_id = await page.evaluate(() => zulip_test.current_msg_list?.last()?.id);
|
||||
assert.ok(msg_last_id !== undefined);
|
||||
|
||||
// Navigate to the combined feed (all sent messages are now marked as read).
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
await common.get_current_msg_list_id(page, true);
|
||||
|
||||
// Mark all messages in the topic as unread so the gate has something to gate on.
|
||||
await page.evaluate(
|
||||
(sid: number, topic: string) => {
|
||||
zulip_test.mark_topic_as_unread(sid, topic);
|
||||
},
|
||||
stream_id,
|
||||
gate_topic,
|
||||
);
|
||||
|
||||
// Wait for the client's unread state to reflect the server's response.
|
||||
await page.waitForFunction(
|
||||
(sid: number, topic: string) => zulip_test.num_unread_for_topic(sid, topic) > 0,
|
||||
{timeout: 10000},
|
||||
stream_id,
|
||||
gate_topic,
|
||||
);
|
||||
|
||||
// Navigate via /near/ anchored at the last message. The first unread
|
||||
// (message 1) is above the viewport, so the gate should stay pending.
|
||||
await page.goto(
|
||||
`${realm_url}#narrow/channel/${stream_id}-${stream_name}/topic/${gate_topic}/near/${msg_last_id}`,
|
||||
);
|
||||
await common.get_current_msg_list_id(page, true);
|
||||
|
||||
// Gate must still be pending: the first unread is above the visible area.
|
||||
const gate_pending = await page.evaluate(
|
||||
() => zulip_test.current_msg_list?.near_view_reading_gate_pending,
|
||||
);
|
||||
assert.ok(gate_pending, "Gate should be pending when first unread is above the viewport");
|
||||
let reading_prevented = await page.evaluate(
|
||||
() => zulip_test.current_msg_list?.reading_prevented,
|
||||
);
|
||||
assert.equal(
|
||||
reading_prevented,
|
||||
true,
|
||||
"reading_prevented should be false after scrolling first unread into view",
|
||||
);
|
||||
// Scroll to the top so the first unread enters the visible area, then
|
||||
// directly call maybe_resume_reading_for_near_view to check whether
|
||||
// the gate clears. We call it directly (rather than relying on the
|
||||
// scroll handler) because the test scroll and the initial render's
|
||||
// anchor scroll share a debounce timer, causing scroll_finished() to
|
||||
// fire once with update_selection_on_next_scroll=false and skip the
|
||||
// gate check. This matches how render_message_list_with_selected_message
|
||||
// calls the function directly during the initial render.
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.scrollTop = 0;
|
||||
zulip_test.current_msg_list?.maybe_resume_reading_for_near_view();
|
||||
});
|
||||
|
||||
// The gate should now clear since the first unread is visible.
|
||||
await page.waitForFunction(
|
||||
() => zulip_test.current_msg_list?.near_view_reading_gate_pending === false,
|
||||
{timeout: 5000},
|
||||
);
|
||||
reading_prevented = await page.evaluate(() => zulip_test.current_msg_list?.reading_prevented);
|
||||
assert.equal(
|
||||
reading_prevented,
|
||||
false,
|
||||
"reading_prevented should be false after scrolling first unread into view",
|
||||
);
|
||||
}
|
||||
|
||||
async function mark_messages_read_near_test(page: Page): Promise<void> {
|
||||
await common.log_in(page);
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
await common.get_current_msg_list_id(page, true);
|
||||
|
||||
await test_near_narrow_is_conversation_view(page);
|
||||
await test_near_narrow_reading_gate_clears(page);
|
||||
await test_near_narrow_no_conversation_only_banner(page);
|
||||
await test_near_narrow_gate_requires_scroll(page);
|
||||
}
|
||||
|
||||
await common.run_test(mark_messages_read_near_test);
|
||||
49
web/e2e-tests/mention.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {Page} from "puppeteer";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
|
||||
async function test_mention(page: Page): Promise<void> {
|
||||
await common.log_in(page);
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
let message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(
|
||||
`.message-list[data-message-list-id='${message_list_id}'] .message_row`,
|
||||
{visible: true},
|
||||
);
|
||||
await page.keyboard.press("KeyC");
|
||||
await page.waitForSelector("#compose", {visible: true});
|
||||
|
||||
await common.select_stream_in_compose_via_dropdown(page, "Verona");
|
||||
await common.fill_form(page, 'form[action^="/json/messages"]', {
|
||||
stream_message_recipient_topic: "Test mention all",
|
||||
});
|
||||
await common.select_item_via_typeahead(page, "#compose-textarea", "@**all", "all");
|
||||
await common.ensure_enter_does_not_send(page);
|
||||
|
||||
console.log("Checking for all everyone warning");
|
||||
const stream_size = await page.evaluate(() =>
|
||||
zulip_test.get_subscriber_count(zulip_test.get_sub("Verona")!.stream_id),
|
||||
);
|
||||
const threshold = await page.evaluate(() => {
|
||||
zulip_test.set_wildcard_mention_threshold(5);
|
||||
return zulip_test.wildcard_mention_threshold;
|
||||
});
|
||||
assert.ok(stream_size > threshold);
|
||||
await page.click("#compose-send-button");
|
||||
|
||||
await page.waitForSelector(
|
||||
"#compose_banners .wildcard_warning .main-view-banner-action-button",
|
||||
{visible: true},
|
||||
);
|
||||
await page.click("#compose_banners .wildcard_warning .main-view-banner-action-button");
|
||||
await page.waitForSelector(".wildcard_warning", {hidden: true});
|
||||
|
||||
message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await common.check_messages_sent(page, message_list_id, [
|
||||
["Verona > Test mention all", ["@all"]],
|
||||
]);
|
||||
}
|
||||
|
||||
await common.run_test(test_mention);
|
||||
538
web/e2e-tests/message-basics.test.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {Page} from "puppeteer";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
|
||||
async function get_stream_li(page: Page, stream_name: string): Promise<string> {
|
||||
const stream_id = await common.get_stream_id(page, stream_name);
|
||||
assert.ok(stream_id !== undefined);
|
||||
return `#stream_filters .narrow-filter[data-stream-id="${CSS.escape(stream_id.toString())}"]`;
|
||||
}
|
||||
|
||||
async function expect_home(page: Page): Promise<void> {
|
||||
const message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
|
||||
visible: true,
|
||||
});
|
||||
// Assert that there is only one message list.
|
||||
assert.equal((await page.$$(".message-list")).length, 1);
|
||||
assert.strictEqual(await page.title(), "Combined feed - Zulip Dev - Zulip");
|
||||
await common.check_messages_sent(page, message_list_id, [
|
||||
["Verona > test", ["verona test a", "verona test b"]],
|
||||
["Verona > other topic", ["verona other topic c"]],
|
||||
["Denmark > test", ["denmark message"]],
|
||||
[
|
||||
"You and Cordelia, Lear's daughter, King Hamlet",
|
||||
["group direct message a", "group direct message b"],
|
||||
],
|
||||
["You and Cordelia, Lear's daughter", ["direct message c"]],
|
||||
["Verona > test", ["verona test d"]],
|
||||
["You and Cordelia, Lear's daughter, King Hamlet", ["group direct message d"]],
|
||||
["You and Cordelia, Lear's daughter", ["direct message e"]],
|
||||
]);
|
||||
}
|
||||
|
||||
async function expect_verona_stream_top_topic(page: Page): Promise<void> {
|
||||
const message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
|
||||
visible: true,
|
||||
});
|
||||
await common.check_messages_sent(page, message_list_id, [
|
||||
["Verona > test", ["verona test a", "verona test b", "verona test d"]],
|
||||
]);
|
||||
assert.strictEqual(await page.title(), "#Verona > test - Zulip Dev - Zulip");
|
||||
}
|
||||
|
||||
async function expect_verona_stream(page: Page): Promise<void> {
|
||||
const message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
|
||||
visible: true,
|
||||
});
|
||||
await common.check_messages_sent(page, message_list_id, [
|
||||
["Verona > test", ["verona test a", "verona test b"]],
|
||||
["Verona > other topic", ["verona other topic c"]],
|
||||
["Verona > test", ["verona test d"]],
|
||||
]);
|
||||
assert.strictEqual(await page.title(), "#Verona - Zulip Dev - Zulip");
|
||||
}
|
||||
|
||||
async function expect_verona_stream_test_topic(page: Page): Promise<void> {
|
||||
const message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
|
||||
visible: true,
|
||||
});
|
||||
await common.check_messages_sent(page, message_list_id, [
|
||||
["Verona > test", ["verona test a", "verona test b", "verona test d"]],
|
||||
]);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, "#new_conversation_button"),
|
||||
"Start new conversation",
|
||||
);
|
||||
}
|
||||
|
||||
async function expect_verona_other_topic(page: Page): Promise<void> {
|
||||
const message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
|
||||
visible: true,
|
||||
});
|
||||
await common.check_messages_sent(page, message_list_id, [
|
||||
["Verona > other topic", ["verona other topic c"]],
|
||||
]);
|
||||
}
|
||||
|
||||
async function expect_test_topic(page: Page): Promise<void> {
|
||||
const message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
|
||||
visible: true,
|
||||
});
|
||||
await common.check_messages_sent(page, message_list_id, [
|
||||
["Verona > test", ["verona test a", "verona test b"]],
|
||||
["Denmark > test", ["denmark message"]],
|
||||
["Verona > test", ["verona test d"]],
|
||||
]);
|
||||
}
|
||||
|
||||
async function expect_group_direct_messages(page: Page): Promise<void> {
|
||||
const message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
|
||||
visible: true,
|
||||
});
|
||||
await common.check_messages_sent(page, message_list_id, [
|
||||
[
|
||||
"You and Cordelia, Lear's daughter, King Hamlet",
|
||||
["group direct message a", "group direct message b", "group direct message d"],
|
||||
],
|
||||
]);
|
||||
assert.strictEqual(
|
||||
await page.title(),
|
||||
"Cordelia, Lear's daughter, King Hamlet - Zulip Dev - Zulip",
|
||||
);
|
||||
}
|
||||
|
||||
async function expect_cordelia_direct_messages(page: Page): Promise<void> {
|
||||
const message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
|
||||
visible: true,
|
||||
});
|
||||
await common.check_messages_sent(page, message_list_id, [
|
||||
["You and Cordelia, Lear's daughter", ["direct message c", "direct message e"]],
|
||||
]);
|
||||
}
|
||||
|
||||
async function un_narrow(page: Page): Promise<void> {
|
||||
if ((await (await page.$(".message_comp"))!.boundingBox())?.height) {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
}
|
||||
|
||||
async function un_narrow_by_clicking_org_icon(page: Page): Promise<void> {
|
||||
await page.click(".brand");
|
||||
}
|
||||
|
||||
async function expect_recent_view(page: Page): Promise<void> {
|
||||
await page.waitForSelector("#recent_view_table", {visible: true});
|
||||
assert.strictEqual(await page.title(), "Recent conversations - Zulip Dev - Zulip");
|
||||
}
|
||||
|
||||
async function test_navigations_from_home(page: Page): Promise<void> {
|
||||
return; // No idea why this is broken.
|
||||
console.log("Narrowing by clicking stream");
|
||||
await page.click(`.focused-message-list [title='Narrow to stream "Verona"']`);
|
||||
await expect_verona_stream(page);
|
||||
|
||||
assert.strictEqual(await page.title(), "#Verona - Zulip Dev - Zulip");
|
||||
await un_narrow(page);
|
||||
await expect_home(page);
|
||||
|
||||
console.log("Narrowing by clicking topic");
|
||||
await page.click(`.focused-message-list [title='Narrow to stream "Verona", topic "test"']`);
|
||||
await expect_verona_stream_test_topic(page);
|
||||
|
||||
await un_narrow(page);
|
||||
await expect_home(page);
|
||||
|
||||
return; // TODO: rest of this test seems nondeterministically broken
|
||||
console.log("Narrowing by clicking group personal header");
|
||||
await page.click(
|
||||
`.focused-message-list [title="Narrow to your direct messages with Cordelia, Lear's daughter, King Hamlet"]`,
|
||||
);
|
||||
await expect_group_direct_messages(page);
|
||||
|
||||
await un_narrow(page);
|
||||
await expect_home(page);
|
||||
|
||||
await page.click(
|
||||
`.focused-message-list [title="Narrow to your direct messages with Cordelia, Lear's daughter, King Hamlet"]`,
|
||||
);
|
||||
await un_narrow_by_clicking_org_icon(page);
|
||||
await expect_recent_view(page);
|
||||
}
|
||||
|
||||
async function search_and_check(
|
||||
page: Page,
|
||||
search_str: string,
|
||||
item_to_select: string,
|
||||
check: (page: Page) => Promise<void>,
|
||||
expected_narrow_title: string,
|
||||
): Promise<void> {
|
||||
await page.click(".search_icon");
|
||||
await page.waitForSelector(".navbar-search.expanded", {visible: true});
|
||||
await common.select_item_via_typeahead(page, "#search_query", search_str, item_to_select);
|
||||
// Enter to trigger search
|
||||
await page.keyboard.press("Enter");
|
||||
await check(page);
|
||||
assert.strictEqual(await page.title(), expected_narrow_title);
|
||||
await un_narrow(page);
|
||||
await expect_home(page);
|
||||
}
|
||||
|
||||
async function search_silent_user(page: Page, str: string, item: string): Promise<void> {
|
||||
await page.click(".search_icon");
|
||||
await page.waitForSelector(".navbar-search.expanded", {visible: true});
|
||||
await common.select_item_via_typeahead(page, "#search_query", str, item);
|
||||
// Enter to trigger search
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForSelector(".empty_feed_notice", {visible: true});
|
||||
const expect_message = "You haven't received any messages sent by Email Gateway yet.";
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".empty-feed-notice-title"),
|
||||
expect_message,
|
||||
);
|
||||
await common.get_current_msg_list_id(page, true);
|
||||
await un_narrow(page);
|
||||
await expect_home(page);
|
||||
}
|
||||
|
||||
async function search_tests(page: Page): Promise<void> {
|
||||
await search_and_check(
|
||||
page,
|
||||
"Verona",
|
||||
"channel: Verona",
|
||||
expect_verona_stream,
|
||||
"#Verona - Zulip Dev - Zulip",
|
||||
);
|
||||
|
||||
await search_and_check(
|
||||
page,
|
||||
"Cordelia",
|
||||
"dm:",
|
||||
expect_cordelia_direct_messages,
|
||||
"Cordelia, Lear's daughter - Zulip Dev - Zulip",
|
||||
);
|
||||
|
||||
await search_and_check(
|
||||
page,
|
||||
"stream:Verona",
|
||||
"",
|
||||
expect_verona_stream,
|
||||
"#Verona - Zulip Dev - Zulip",
|
||||
);
|
||||
|
||||
await search_and_check(
|
||||
page,
|
||||
"stream:Verona topic:test",
|
||||
"",
|
||||
expect_verona_stream_test_topic,
|
||||
"#Verona > test - Zulip Dev - Zulip",
|
||||
);
|
||||
|
||||
await search_and_check(
|
||||
page,
|
||||
"stream:Verona topic:other+topic",
|
||||
"",
|
||||
expect_verona_other_topic,
|
||||
"#Verona > other topic - Zulip Dev - Zulip",
|
||||
);
|
||||
|
||||
await search_and_check(
|
||||
page,
|
||||
"topic:test",
|
||||
"",
|
||||
expect_test_topic,
|
||||
"Search results - Zulip Dev - Zulip",
|
||||
);
|
||||
|
||||
await search_silent_user(page, "sender:emailgateway@zulip.com", "");
|
||||
}
|
||||
|
||||
async function expect_all_direct_messages(page: Page): Promise<void> {
|
||||
const message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
|
||||
visible: true,
|
||||
});
|
||||
await common.check_messages_sent(page, message_list_id, [
|
||||
[
|
||||
"You and Cordelia, Lear's daughter, King Hamlet",
|
||||
["group direct message a", "group direct message b"],
|
||||
],
|
||||
["You and Cordelia, Lear's daughter", ["direct message c"]],
|
||||
["You and Cordelia, Lear's daughter, King Hamlet", ["group direct message d"]],
|
||||
["You and Cordelia, Lear's daughter", ["direct message e"]],
|
||||
]);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, "#new_conversation_button"),
|
||||
"Start new conversation",
|
||||
);
|
||||
assert.strictEqual(await page.title(), "Direct message feed - Zulip Dev - Zulip");
|
||||
}
|
||||
|
||||
async function test_narrow_by_clicking_the_left_sidebar(page: Page): Promise<void> {
|
||||
console.log("Narrowing with left sidebar");
|
||||
|
||||
await page.click((await get_stream_li(page, "Verona")) + " .stream-name");
|
||||
await expect_verona_stream_top_topic(page);
|
||||
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages a");
|
||||
await expect_home(page);
|
||||
|
||||
const all_private_messages_icon = ".show-all-direct-messages";
|
||||
await page.waitForSelector(all_private_messages_icon, {visible: true});
|
||||
await page.click(all_private_messages_icon);
|
||||
await expect_all_direct_messages(page);
|
||||
|
||||
await un_narrow(page);
|
||||
await expect_home(page);
|
||||
}
|
||||
|
||||
async function arrow(page: Page, direction: "Up" | "Down"): Promise<void> {
|
||||
await page.keyboard.press(({Up: "ArrowUp", Down: "ArrowDown"} as const)[direction]);
|
||||
}
|
||||
|
||||
async function test_search_venice(page: Page): Promise<void> {
|
||||
await common.clear_and_type(page, ".left-sidebar-search-input", "vEnI"); // Must be case insensitive.
|
||||
await page.waitForSelector(await get_stream_li(page, "Denmark"), {hidden: true});
|
||||
await page.waitForSelector(await get_stream_li(page, "Verona"), {hidden: true});
|
||||
await arrow(page, "Down");
|
||||
await page.waitForSelector((await get_stream_li(page, "Venice")) + " .highlighted_row", {
|
||||
visible: true,
|
||||
});
|
||||
|
||||
// Clearing list gives back all the streams in the list
|
||||
await common.clear_and_type(page, ".left-sidebar-search-input", "");
|
||||
await page.waitForSelector(await get_stream_li(page, "Denmark"), {visible: true});
|
||||
await page.waitForSelector(await get_stream_li(page, "Venice"), {visible: true});
|
||||
await page.waitForSelector(await get_stream_li(page, "Verona"), {visible: true});
|
||||
}
|
||||
|
||||
async function test_stream_search_filters_stream_list(page: Page): Promise<void> {
|
||||
console.log("Filter streams using left side bar");
|
||||
|
||||
await page.waitForSelector(".left-sidebar-search-section");
|
||||
|
||||
// assert streams exist by waiting till they're visible
|
||||
await page.waitForSelector(await get_stream_li(page, "Denmark"), {visible: true});
|
||||
await page.waitForSelector(await get_stream_li(page, "Venice"), {visible: true});
|
||||
await page.waitForSelector(await get_stream_li(page, "Verona"), {visible: true});
|
||||
|
||||
// Enter the search box and test highlighted suggestion
|
||||
await page.click(".left-sidebar-search-input");
|
||||
|
||||
// Selection is not highlighted until user wants to move the cursor.
|
||||
await page.waitForSelector(".top_left_inbox.top_left_row.highlighted_row", {hidden: true});
|
||||
await arrow(page, "Down");
|
||||
await page.waitForSelector(".top_left_inbox.top_left_row.highlighted_row", {visible: true});
|
||||
|
||||
await page.waitForSelector((await get_stream_li(page, "Verona")) + " .highlighted_row", {
|
||||
hidden: true,
|
||||
});
|
||||
|
||||
// Navigate through suggestions using arrow keys
|
||||
// Reach core team
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
await arrow(page, "Down");
|
||||
}
|
||||
await arrow(page, "Down"); // core team -> Denmark
|
||||
await arrow(page, "Down"); // Denmark -> sandbox
|
||||
await arrow(page, "Up"); // sandbox -> Denmark
|
||||
await arrow(page, "Up"); // Denmark -> core team
|
||||
await arrow(page, "Up"); // core team -> core team
|
||||
await arrow(page, "Down"); // core team -> Denmark
|
||||
await arrow(page, "Down"); // Denmark -> sandbox
|
||||
await arrow(page, "Down"); // sandbox-> Venice
|
||||
await arrow(page, "Down"); // Venice -> Verona
|
||||
|
||||
await page.waitForSelector((await get_stream_li(page, "Verona")) + " .highlighted_row", {
|
||||
visible: true,
|
||||
});
|
||||
|
||||
await page.waitForSelector((await get_stream_li(page, "core team")) + " .highlighted_row", {
|
||||
hidden: true,
|
||||
});
|
||||
await page.waitForSelector((await get_stream_li(page, "Denmark")) + " .highlighted_row", {
|
||||
hidden: true,
|
||||
});
|
||||
await page.waitForSelector((await get_stream_li(page, "Venice")) + " .highlighted_row", {
|
||||
hidden: true,
|
||||
});
|
||||
await page.waitForSelector((await get_stream_li(page, "Zulip")) + " .highlighted_row", {
|
||||
hidden: true,
|
||||
});
|
||||
await test_search_venice(page);
|
||||
|
||||
// Search for beginning of "Verona".
|
||||
await page.type(".left-sidebar-search-input", "ver");
|
||||
await page.waitForSelector(await get_stream_li(page, "core team"), {hidden: true});
|
||||
await page.waitForSelector(await get_stream_li(page, "Denmark"), {hidden: true});
|
||||
await page.waitForSelector(await get_stream_li(page, "Venice"), {hidden: true});
|
||||
await page.click(await get_stream_li(page, "Verona"));
|
||||
await expect_verona_stream_top_topic(page);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".left-sidebar-search-input"),
|
||||
"",
|
||||
"Clicking on stream didn't clear search",
|
||||
);
|
||||
await un_narrow(page);
|
||||
}
|
||||
|
||||
async function test_users_search(page: Page): Promise<void> {
|
||||
console.log("Search users using right sidebar");
|
||||
async function assert_in_list(page: Page, name: string): Promise<void> {
|
||||
await page.waitForSelector(`#buddy-list-other-users li[data-name="${CSS.escape(name)}"]`, {
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function assert_selected(page: Page, name: string): Promise<void> {
|
||||
await page.waitForSelector(
|
||||
`#buddy-list-other-users li.highlighted_user[data-name="${CSS.escape(name)}"]`,
|
||||
{visible: true},
|
||||
);
|
||||
}
|
||||
|
||||
async function assert_not_selected(page: Page, name: string): Promise<void> {
|
||||
await page.waitForSelector(
|
||||
`#buddy-list-other-users li.highlighted_user[data-name="${CSS.escape(name)}"]`,
|
||||
{hidden: true},
|
||||
);
|
||||
}
|
||||
|
||||
await assert_in_list(page, "Desdemona");
|
||||
await assert_in_list(page, "Cordelia, Lear's daughter");
|
||||
await assert_in_list(page, "King Hamlet");
|
||||
await assert_in_list(page, "aaron");
|
||||
|
||||
// Enter the search box and test selected suggestion navigation
|
||||
await page.click(".user-list-filter");
|
||||
// Selection is not highlighted until user wants to move the cursor.
|
||||
await page.waitForSelector("#buddy-list-other-users .highlighted_user", {hidden: true});
|
||||
await arrow(page, "Down");
|
||||
await page.waitForSelector("#buddy-list-other-users .highlighted_user", {visible: true});
|
||||
await assert_selected(page, "Desdemona");
|
||||
await assert_not_selected(page, "Cordelia, Lear's daughter");
|
||||
await assert_not_selected(page, "King Hamlet");
|
||||
await assert_not_selected(page, "aaron");
|
||||
|
||||
// Navigate using arrow keys.
|
||||
// go down 2, up 3, then down 3
|
||||
// Desdemona
|
||||
// aaron
|
||||
// Cordelia, Lear's daughter
|
||||
// Iago
|
||||
await arrow(page, "Down");
|
||||
await arrow(page, "Down");
|
||||
await arrow(page, "Up");
|
||||
await arrow(page, "Up");
|
||||
await arrow(page, "Up"); // does nothing; already on the top.
|
||||
await arrow(page, "Down");
|
||||
await arrow(page, "Down");
|
||||
await arrow(page, "Down");
|
||||
|
||||
// Now Iago must be highlighted
|
||||
await page.waitForSelector('#buddy-list-other-users li.highlighted_user[data-name="Iago"]', {
|
||||
visible: true,
|
||||
});
|
||||
await assert_not_selected(page, "King Hamlet");
|
||||
await assert_not_selected(page, "aaron");
|
||||
await assert_not_selected(page, "Desdemona");
|
||||
|
||||
// arrow up and press Enter. We should be taken to direct messages with Cordelia, Lear's daughter
|
||||
await arrow(page, "Up");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect_cordelia_direct_messages(page);
|
||||
}
|
||||
|
||||
async function test_narrow_public_streams(page: Page): Promise<void> {
|
||||
const stream_id = await common.get_stream_id(page, "Denmark");
|
||||
await page.goto(`http://zulip.zulipdev.com:9981/#channels/${stream_id}/Denmark`);
|
||||
await page.waitForSelector("button.sub_unsub_button", {visible: true});
|
||||
await page.click("button.sub_unsub_button");
|
||||
await page.waitForSelector(
|
||||
`xpath///button[${common.has_class_x(
|
||||
"sub_unsub_button",
|
||||
)} and normalize-space()="Subscribe"]`,
|
||||
);
|
||||
await page.click("#subscription_overlay .two-pane-settings-header .exit-sign");
|
||||
await page.waitForSelector("#subscription_overlay", {hidden: true});
|
||||
await page.goto(`http://zulip.zulipdev.com:9981/#narrow/channel/${stream_id}-Denmark`);
|
||||
let message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(
|
||||
`.message-list[data-message-list-id='${message_list_id}'] .recipient_row ~ .recipient_row ~ .recipient_row`,
|
||||
);
|
||||
assert.ok(
|
||||
(await page.$(
|
||||
`.message-list[data-message-list-id='${message_list_id}'] .stream-status, .message-list[data-message-list-id='${message_list_id}'] .not-subscribed-banner`,
|
||||
)) !== null,
|
||||
);
|
||||
|
||||
await page.goto("http://zulip.zulipdev.com:9981/#narrow/channels/public");
|
||||
message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(
|
||||
`.message-list[data-message-list-id='${message_list_id}'] .recipient_row ~ .recipient_row ~ .recipient_row`,
|
||||
);
|
||||
assert.ok(
|
||||
(await page.$(
|
||||
`.message-list[data-message-list-id='${message_list_id}'] .stream-status`,
|
||||
)) === null,
|
||||
);
|
||||
}
|
||||
|
||||
async function message_basic_tests(page: Page): Promise<void> {
|
||||
await common.log_in(page);
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
const message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(
|
||||
`.message-list[data-message-list-id='${message_list_id}'] .message_row`,
|
||||
{visible: true},
|
||||
);
|
||||
|
||||
console.log("Sending messages");
|
||||
await common.send_multiple_messages(page, [
|
||||
{stream_name: "Verona", topic: "test", content: "verona test a"},
|
||||
{stream_name: "Verona", topic: "test", content: "verona test b"},
|
||||
{
|
||||
stream_name: "Verona",
|
||||
topic: "other topic",
|
||||
content: "verona other topic c",
|
||||
},
|
||||
{stream_name: "Denmark", topic: "test", content: "denmark message"},
|
||||
{
|
||||
recipient: "cordelia@zulip.com, hamlet@zulip.com",
|
||||
content: "group direct message a",
|
||||
},
|
||||
{
|
||||
recipient: "cordelia@zulip.com, hamlet@zulip.com",
|
||||
content: "group direct message b",
|
||||
},
|
||||
{recipient: "cordelia@zulip.com", content: "direct message c"},
|
||||
{stream_name: "Verona", topic: "test", content: "verona test d"},
|
||||
{
|
||||
recipient: "cordelia@zulip.com, hamlet@zulip.com",
|
||||
content: "group direct message d",
|
||||
},
|
||||
{recipient: "cordelia@zulip.com", content: "direct message e"},
|
||||
]);
|
||||
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
await expect_home(page);
|
||||
|
||||
await test_navigations_from_home(page);
|
||||
await search_tests(page);
|
||||
await test_narrow_by_clicking_the_left_sidebar(page);
|
||||
await test_stream_search_filters_stream_list(page);
|
||||
await test_users_search(page);
|
||||
await test_narrow_public_streams(page);
|
||||
}
|
||||
|
||||
await common.run_test(message_basic_tests);
|
||||
119
web/e2e-tests/navigation.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {Page} from "puppeteer";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
|
||||
async function navigate_using_left_sidebar(page: Page, stream_name: string): Promise<void> {
|
||||
console.log("Visiting #" + stream_name);
|
||||
const stream_id = await page.evaluate(() => zulip_test.get_sub("Verona")!.stream_id);
|
||||
await page.click(`.narrow-filter[data-stream-id="${stream_id}"] .stream-name`);
|
||||
await page.waitForSelector("#message_view_header .zulip-icon-hashtag", {visible: true});
|
||||
}
|
||||
|
||||
async function open_menu(page: Page): Promise<void> {
|
||||
const menu_selector = "#settings-dropdown";
|
||||
await page.waitForSelector(menu_selector, {visible: true});
|
||||
await page.click(menu_selector);
|
||||
}
|
||||
|
||||
async function navigate_to_settings(page: Page): Promise<void> {
|
||||
console.log("Navigating to settings");
|
||||
|
||||
await common.open_personal_menu(page);
|
||||
|
||||
const settings_selector = "#personal-menu-dropdown a[href^='#settings']";
|
||||
await page.waitForSelector(settings_selector, {visible: true});
|
||||
await page.click(settings_selector);
|
||||
|
||||
const profile_section_tab_selector = "li[data-section='profile']";
|
||||
await page.waitForSelector(profile_section_tab_selector, {visible: true});
|
||||
await page.click(profile_section_tab_selector);
|
||||
await page.waitForSelector(`${profile_section_tab_selector}:focus`, {visible: true});
|
||||
|
||||
await page.click("#settings_page .content-wrapper .exit");
|
||||
// Wait until the overlay is completely closed.
|
||||
await page.waitForSelector("#settings_overlay_container", {hidden: true});
|
||||
}
|
||||
|
||||
async function navigate_to_subscriptions(page: Page): Promise<void> {
|
||||
console.log("Navigate to subscriptions");
|
||||
|
||||
await open_menu(page);
|
||||
|
||||
const manage_streams_selector = '.link-item a[href^="#channels"]';
|
||||
await page.waitForSelector(manage_streams_selector, {visible: true});
|
||||
await page.click(manage_streams_selector);
|
||||
|
||||
await page.waitForSelector("#subscription_overlay", {visible: true});
|
||||
|
||||
await page.click("#subscription_overlay .exit");
|
||||
// Wait until the overlay is completely closed.
|
||||
await page.waitForSelector("#subscription_overlay", {hidden: true});
|
||||
}
|
||||
|
||||
async function navigate_to_private_messages(page: Page): Promise<void> {
|
||||
console.log("Navigate to direct messages");
|
||||
|
||||
const all_private_messages_icon = ".show-all-direct-messages";
|
||||
await page.waitForSelector(all_private_messages_icon, {visible: true});
|
||||
await page.click(all_private_messages_icon);
|
||||
|
||||
await page.waitForSelector("#message_view_header .zulip-icon-user", {visible: true});
|
||||
}
|
||||
|
||||
async function test_reload_hash(page: Page): Promise<void> {
|
||||
const initial_page_load_time = await page.evaluate(() => zulip_test.page_load_time);
|
||||
assert.ok(initial_page_load_time !== undefined);
|
||||
console.log(`initial load time: ${initial_page_load_time}`);
|
||||
|
||||
const initial_hash = await page.evaluate(() => window.location.hash);
|
||||
|
||||
await page.evaluate(() => {
|
||||
zulip_test.initiate_reload({immediate: true});
|
||||
});
|
||||
await page.waitForNavigation();
|
||||
const message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(`.message-list[data-message-list-id='${message_list_id}']`, {
|
||||
visible: true,
|
||||
});
|
||||
|
||||
const page_load_time = await page.evaluate(() => zulip_test.page_load_time);
|
||||
assert.ok(page_load_time !== undefined);
|
||||
assert.ok(page_load_time > initial_page_load_time, "Page not reloaded.");
|
||||
|
||||
const hash = await page.evaluate(() => window.location.hash);
|
||||
assert.strictEqual(hash, initial_hash, "Hash not preserved.");
|
||||
}
|
||||
|
||||
async function navigation_tests(page: Page): Promise<void> {
|
||||
await common.log_in(page);
|
||||
|
||||
await navigate_to_settings(page);
|
||||
|
||||
await navigate_using_left_sidebar(page, "Verona");
|
||||
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
await page.waitForSelector("#message_view_header .zulip-icon-all-messages", {visible: true});
|
||||
|
||||
await navigate_to_subscriptions(page);
|
||||
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
await page.waitForSelector("#message_view_header .zulip-icon-all-messages", {visible: true});
|
||||
|
||||
await navigate_to_settings(page);
|
||||
await navigate_to_private_messages(page);
|
||||
await navigate_to_subscriptions(page);
|
||||
await navigate_using_left_sidebar(page, "Verona");
|
||||
|
||||
await test_reload_hash(page);
|
||||
|
||||
// Verify that we're narrowed to the target stream
|
||||
await page.waitForSelector(
|
||||
`xpath///*[@id="message_view_header"]//*[${common.has_class_x(
|
||||
"message-header-stream-settings-button",
|
||||
)} and normalize-space()="Verona"]`,
|
||||
);
|
||||
}
|
||||
|
||||
await common.run_test(navigation_tests);
|
||||
90
web/e2e-tests/realm-creation.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {Page} from "puppeteer";
|
||||
import * as z from "zod/mini";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
|
||||
const email = "alice@test.example.com";
|
||||
const organization_name = "Awesome Organization";
|
||||
const host = "zulipdev.com:9981";
|
||||
|
||||
async function realm_creation_tests(page: Page): Promise<void> {
|
||||
await page.goto("http://" + host + "/new/");
|
||||
|
||||
// submit the email for realm creation.
|
||||
await page.waitForSelector("#email");
|
||||
await page.type("#email", email);
|
||||
await page.type("#id_team_name", organization_name);
|
||||
await page.select("#realm_type", "business");
|
||||
await page.$eval("input#realm_in_root_domain", (el) => {
|
||||
el.click();
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
page.waitForNavigation(),
|
||||
page.$eval("form#create_realm", (form) => {
|
||||
form.submit();
|
||||
}),
|
||||
]);
|
||||
|
||||
// Make sure confirmation email is sent.
|
||||
assert.ok(page.url().includes("/accounts/new/send_confirm/?email=alice%40test.example.com"));
|
||||
|
||||
// Special endpoint enabled only during tests for extracting confirmation key
|
||||
await page.goto("http://" + host + "/confirmation_key/");
|
||||
|
||||
// Open the confirmation URL
|
||||
const page_content = await page.evaluate(() => document.querySelector("body")!.textContent);
|
||||
assert.ok(page_content !== null);
|
||||
const {confirmation_key} = z
|
||||
.object({confirmation_key: z.string()})
|
||||
.parse(JSON.parse(page_content));
|
||||
const confirmation_url = `http://${host}/accounts/do_confirm/${confirmation_key}`;
|
||||
await page.goto(confirmation_url);
|
||||
|
||||
// We wait until the DOMContentLoaded event because we want the code
|
||||
// that focuses the first input field to run before we run our tests to avoid
|
||||
// flakes. Without waiting for DOMContentLoaded event, in rare cases, the
|
||||
// first input is focused when we are typing something for other fields causing
|
||||
// validation errors. The code for focusing the input is wrapped in jQuery
|
||||
// $() calls which runs when DOMContentLoaded is fired.
|
||||
await page.waitForNavigation({waitUntil: "domcontentloaded"});
|
||||
|
||||
// Make sure the realm creation page is loaded correctly by
|
||||
// checking the text in <p> tag under pitch class is as expected.
|
||||
await page.waitForSelector(".pitch");
|
||||
const text_in_pitch = await page.evaluate(
|
||||
() => document.querySelector(".pitch p")!.textContent,
|
||||
);
|
||||
assert.equal(text_in_pitch, "Enter your account details to complete registration.");
|
||||
|
||||
// fill the form.
|
||||
const params = {
|
||||
full_name: "Alice",
|
||||
password: "passwordwhichisnotreallycomplex",
|
||||
terms: true,
|
||||
how_realm_creator_found_zulip: "other",
|
||||
how_realm_creator_found_zulip_other_text: "test",
|
||||
};
|
||||
// For some reason, page.click() does not work this for particular checkbox
|
||||
// so use page.$eval here to call the .click method in the browser.
|
||||
await common.fill_form(page, "#registration", params);
|
||||
await page.$eval("form#registration", (form) => {
|
||||
form.submit();
|
||||
});
|
||||
|
||||
// Check if realm is created and user is logged in by checking if
|
||||
// element of id `lightbox_overlay` exists.
|
||||
await page.waitForSelector("#lightbox_overlay"); // if element doesn't exist,timeout error raises
|
||||
|
||||
// Check if the modal having the onboarding video has been displayed.
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
await page.click("#navigation-tour-video-modal .modal__close");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
|
||||
// Updating common.realm_url because we are redirecting to it when logging out.
|
||||
common.set_realm_url(page.url());
|
||||
}
|
||||
|
||||
await common.run_test(realm_creation_tests);
|
||||
168
web/e2e-tests/realm-linkifier.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {Page} from "puppeteer";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
|
||||
async function test_add_linkifier(page: Page): Promise<void> {
|
||||
await page.waitForSelector("#add-linkifier-button", {visible: true});
|
||||
await page.click("#add-linkifier-button");
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
await page.waitForSelector("form.linkifier-add-form", {visible: true});
|
||||
await common.fill_form(page, "form.linkifier-add-form", {
|
||||
pattern: "#(?P<id>[0-9]+)",
|
||||
url_template: "https://trac.example.com/ticket/{id}",
|
||||
});
|
||||
await page.click(".dialog_submit_button");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
|
||||
const admin_linkifier_status_selector = "div#linkifier-field-status";
|
||||
await page.waitForSelector(admin_linkifier_status_selector, {visible: true});
|
||||
const admin_linkifier_status = await common.get_text_from_selector(
|
||||
page,
|
||||
admin_linkifier_status_selector,
|
||||
);
|
||||
assert.strictEqual(admin_linkifier_status, "Custom linkifier added!");
|
||||
|
||||
await page.waitForSelector(".linkifier_row:nth-child(4)", {visible: true});
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(
|
||||
page,
|
||||
".linkifier_row:nth-child(4) span.linkifier_pattern",
|
||||
),
|
||||
"#(?P<id>[0-9]+)",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(
|
||||
page,
|
||||
".linkifier_row:nth-child(4) span.linkifier_url_template",
|
||||
),
|
||||
"https://trac.example.com/ticket/{id}",
|
||||
);
|
||||
}
|
||||
|
||||
async function test_delete_linkifier(page: Page): Promise<void> {
|
||||
await page.waitForFunction(() => document.querySelectorAll(".linkifier_row").length === 4);
|
||||
await page.click(".linkifier_row:nth-last-child(1) .delete");
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
await page.click("#confirm_delete_linkifiers_modal .dialog_submit_button");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
await page.waitForFunction(() => document.querySelectorAll(".linkifier_row").length === 3);
|
||||
}
|
||||
|
||||
async function test_add_invalid_linkifier_pattern(page: Page): Promise<void> {
|
||||
await page.click("#add-linkifier-button");
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
await page.waitForSelector("form.linkifier-add-form", {visible: true});
|
||||
await common.fill_form(page, "form.linkifier-add-form", {
|
||||
pattern: "(foo",
|
||||
url_template: "https://trac.example.com/ticket/{id}",
|
||||
});
|
||||
await page.click(".dialog_submit_button");
|
||||
|
||||
// Invalid patterns are rejected client-side by RE2JS compilation before
|
||||
// the form is submitted; the error appears in the pattern-specific status
|
||||
// element and includes RE2JS's description of the compilation failure.
|
||||
await page.waitForSelector("div#linkifier-pattern-status", {visible: true});
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, "div#linkifier-pattern-status"),
|
||||
"Failed: Bad regular expression: missing closing )",
|
||||
);
|
||||
|
||||
await page.click(".dialog_exit_button");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
}
|
||||
|
||||
async function test_edit_linkifier(page: Page): Promise<void> {
|
||||
await page.waitForSelector(".linkifier_row:nth-last-child(1) .edit", {visible: true});
|
||||
await page.click(".linkifier_row:nth-last-child(1) .edit");
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
await common.fill_form(page, "form.linkifier-edit-form", {
|
||||
pattern: "(?P<num>[0-9a-f]{40})",
|
||||
url_template: "https://trac.example.com/commit/{num}",
|
||||
});
|
||||
await page.click(".dialog_submit_button");
|
||||
|
||||
await page.waitForSelector(".micromodal", {hidden: true});
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
|
||||
await page.waitForSelector(".linkifier_row:nth-last-child(1)", {visible: true});
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelector(".linkifier_row:nth-last-child(1) span.linkifier_pattern")
|
||||
?.textContent === "(?P<num>[0-9a-f]{40})",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(
|
||||
page,
|
||||
".linkifier_row:nth-last-child(1) span.linkifier_url_template",
|
||||
),
|
||||
"https://trac.example.com/commit/{num}",
|
||||
);
|
||||
}
|
||||
|
||||
async function test_edit_invalid_linkifier(page: Page): Promise<void> {
|
||||
await page.waitForSelector(".linkifier_row:nth-last-child(1) .edit", {visible: true});
|
||||
await page.click(".linkifier_row:nth-last-child(1) .edit");
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
await common.fill_form(page, "form.linkifier-edit-form", {
|
||||
pattern: "#(?P<id>d????)",
|
||||
url_template: "{id",
|
||||
});
|
||||
await page.click(".dialog_submit_button");
|
||||
|
||||
// Invalid patterns are rejected client-side by RE2JS, so the form
|
||||
// does not submit and the URL template is never checked server-side.
|
||||
const edit_linkifier_pattern_status_selector = "div#linkifier-pattern-status";
|
||||
await page.waitForSelector(edit_linkifier_pattern_status_selector, {visible: true});
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, edit_linkifier_pattern_status_selector),
|
||||
"Failed: Bad regular expression: invalid nested repetition operator",
|
||||
);
|
||||
|
||||
// Fix the pattern; the invalid URL template now surfaces from the server.
|
||||
await common.fill_form(page, "form.linkifier-edit-form", {
|
||||
pattern: "#(?P<id>[0-9]+)",
|
||||
});
|
||||
await page.click(".dialog_submit_button");
|
||||
|
||||
const edit_linkifier_template_status_selector = "div#linkifier-template-status";
|
||||
await page.waitForSelector(edit_linkifier_template_status_selector, {visible: true});
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, edit_linkifier_template_status_selector),
|
||||
"Failed: Invalid URL template.",
|
||||
);
|
||||
|
||||
await page.click(".dialog_exit_button");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
|
||||
await page.waitForSelector(".linkifier_row:nth-last-child(1)", {visible: true});
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(
|
||||
page,
|
||||
".linkifier_row:nth-last-child(1) span.linkifier_pattern",
|
||||
),
|
||||
"(?P<num>[0-9a-f]{40})",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(
|
||||
page,
|
||||
".linkifier_row:nth-last-child(1) span.linkifier_url_template",
|
||||
),
|
||||
"https://trac.example.com/commit/{num}",
|
||||
);
|
||||
}
|
||||
|
||||
async function linkifier_test(page: Page): Promise<void> {
|
||||
await common.log_in(page);
|
||||
await common.manage_organization(page);
|
||||
await page.click("li[data-section='linkifier-settings']");
|
||||
|
||||
await test_add_linkifier(page);
|
||||
await test_edit_linkifier(page);
|
||||
await test_edit_invalid_linkifier(page);
|
||||
await test_add_invalid_linkifier_pattern(page);
|
||||
await test_delete_linkifier(page);
|
||||
}
|
||||
|
||||
await common.run_test(linkifier_test);
|
||||
109
web/e2e-tests/realm-playground.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {Page} from "puppeteer";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
|
||||
type Playground = {
|
||||
playground_name: string;
|
||||
pygments_language: string;
|
||||
url_template: string;
|
||||
};
|
||||
|
||||
async function _add_playground_and_return_status(page: Page, payload: Playground): Promise<string> {
|
||||
await page.waitForSelector(".admin-playground-form", {visible: true});
|
||||
const admin_playground_status_selector = "div#admin-playground-status";
|
||||
|
||||
await common.select_item_via_typeahead(
|
||||
page,
|
||||
"#playground_pygments_language",
|
||||
payload.pygments_language,
|
||||
payload.pygments_language,
|
||||
);
|
||||
|
||||
// Now we can fill and click the submit button.
|
||||
await common.fill_form(page, "form.admin-playground-form", {
|
||||
playground_name: payload.playground_name,
|
||||
url_template: payload.url_template,
|
||||
});
|
||||
// Not sure why, but page.click() doesn't seem to always click the submit button.
|
||||
// So we resort to using eval with the button ID instead.
|
||||
await page.$eval("button#submit_playground_button", (el) => {
|
||||
el.click();
|
||||
});
|
||||
|
||||
// Wait for the request to complete by checking when the button is re-enabled.
|
||||
await page.waitForSelector("button#submit_playground_button:not([disabled])", {visible: true});
|
||||
|
||||
// We return the success/failure status message back to the caller.
|
||||
await page.waitForSelector(admin_playground_status_selector, {visible: true});
|
||||
const admin_playground_status = await common.get_text_from_selector(
|
||||
page,
|
||||
admin_playground_status_selector,
|
||||
);
|
||||
return admin_playground_status;
|
||||
}
|
||||
|
||||
async function test_successful_playground_creation(page: Page): Promise<void> {
|
||||
const payload = {
|
||||
pygments_language: "Python",
|
||||
playground_name: "Python3 playground",
|
||||
url_template: "https://python.example.com?code={code}",
|
||||
};
|
||||
const status = await _add_playground_and_return_status(page, payload);
|
||||
assert.strictEqual(status, "Custom playground added!");
|
||||
await page.waitForSelector(".playground_row", {visible: true});
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(
|
||||
page,
|
||||
".playground_row span.playground_pygments_language",
|
||||
),
|
||||
"Python",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".playground_row span.playground_name"),
|
||||
"Python3 playground",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".playground_row span.playground_url_template"),
|
||||
"https://python.example.com?code={code}",
|
||||
);
|
||||
}
|
||||
|
||||
async function test_invalid_playground_parameters(page: Page): Promise<void> {
|
||||
const payload = {
|
||||
pygments_language: "Python",
|
||||
playground_name: "Python3 playground",
|
||||
url_template: "not_a_url_template{",
|
||||
};
|
||||
let status = await _add_playground_and_return_status(page, payload);
|
||||
assert.strictEqual(status, "Failed: Invalid URL template.");
|
||||
|
||||
payload.url_template = "https://python.example.com?code={code}";
|
||||
payload.pygments_language = "py!@%&";
|
||||
status = await _add_playground_and_return_status(page, payload);
|
||||
assert.strictEqual(status, "Failed: Invalid character in language: !");
|
||||
}
|
||||
|
||||
async function test_successful_playground_deletion(page: Page): Promise<void> {
|
||||
await page.waitForSelector(".playground_row button.delete", {visible: true});
|
||||
await page.click(".playground_row button.delete");
|
||||
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
await page.click("#confirm_delete_code_playgrounds_modal .dialog_submit_button");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
|
||||
await page.waitForSelector(".playground_row", {hidden: true});
|
||||
}
|
||||
|
||||
async function playground_test(page: Page): Promise<void> {
|
||||
await common.log_in(page);
|
||||
await common.manage_organization(page);
|
||||
await page.click("li[data-section='playground-settings']");
|
||||
|
||||
await test_successful_playground_creation(page);
|
||||
await test_invalid_playground_parameters(page);
|
||||
await test_successful_playground_deletion(page);
|
||||
}
|
||||
|
||||
await common.run_test(playground_test);
|
||||
531
web/e2e-tests/settings.test.ts
Normal file
@@ -0,0 +1,531 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {Page} from "puppeteer";
|
||||
|
||||
import {GENERIC_BOT_TYPE, OUTGOING_WEBHOOK_BOT_TYPE} from "../src/bot_type_values.ts";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
import {test_credentials} from "./lib/common.ts";
|
||||
|
||||
const zuliprc_regex =
|
||||
/^data:application\/octet-stream;charset=utf-8,\[api]\nemail=.+\nkey=.+\nsite=.+\n$/;
|
||||
|
||||
async function get_decoded_url_in_selector(page: Page, selector: string): Promise<string> {
|
||||
const a = await page.$(`a:is(${selector})`);
|
||||
return decodeURIComponent(await (await a!.getProperty("href")).jsonValue());
|
||||
}
|
||||
|
||||
async function open_manage_bot_tab(page: Page, user_id: number): Promise<void> {
|
||||
const manage_button_selector = `#personal_your_bots_table .user_row[data-user-id="${user_id}"] .manage-user-button`;
|
||||
await page.waitForSelector(manage_button_selector, {visible: true});
|
||||
await page.click(manage_button_selector);
|
||||
|
||||
// Wait for modal, then go to tab
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
}
|
||||
|
||||
async function open_settings(page: Page): Promise<void> {
|
||||
await common.open_personal_menu(page);
|
||||
|
||||
const settings_selector = "#personal-menu-dropdown a[href^='#settings']";
|
||||
await page.waitForSelector(settings_selector, {visible: true});
|
||||
await page.click(settings_selector);
|
||||
|
||||
await page.waitForSelector("#settings_content .profile-settings-form", {visible: true});
|
||||
const page_url = await common.page_url_with_fragment(page);
|
||||
assert.ok(
|
||||
page_url.includes("/#settings/"),
|
||||
`Page url: ${page_url} does not contain /#settings/`,
|
||||
);
|
||||
// Wait for settings overlay to open.
|
||||
await page.waitForSelector("#settings_overlay_container", {visible: true});
|
||||
}
|
||||
|
||||
async function close_settings_and_date_picker(page: Page): Promise<void> {
|
||||
const date_picker_selector = ".date-field-alt-input";
|
||||
await page.click(date_picker_selector);
|
||||
|
||||
await page.waitForSelector(".flatpickr-calendar", {visible: true});
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForSelector(".flatpickr-calendar", {hidden: true});
|
||||
await page.waitForSelector("#settings_overlay_container", {hidden: true});
|
||||
}
|
||||
|
||||
async function test_change_full_name(page: Page): Promise<void> {
|
||||
await page.waitForSelector("#full_name", {visible: true});
|
||||
await page.click("#full_name");
|
||||
|
||||
const full_name_input_selector = 'input[name="full_name"]';
|
||||
await common.clear_and_type(page, full_name_input_selector, "New name");
|
||||
|
||||
await page.click("#settings_content .profile-settings-form");
|
||||
await page.waitForSelector(".full-name-change-container .alert-success", {visible: true});
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector<HTMLInputElement>("#full_name")?.value === "New name",
|
||||
);
|
||||
}
|
||||
|
||||
async function test_change_password(page: Page): Promise<void> {
|
||||
await page.click("#change_password");
|
||||
|
||||
const change_password_button_selector = "#change_password_modal .dialog_submit_button";
|
||||
await page.waitForSelector(change_password_button_selector, {visible: true});
|
||||
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
await page.type("#old_password", test_credentials.default_user.password);
|
||||
test_credentials.default_user.password = "new_password";
|
||||
await page.type("#new_password", test_credentials.default_user.password);
|
||||
|
||||
// The password strength checker (zxcvbn) is imported asynchronously,
|
||||
// and submitting before it loads makes the handler bail out with an
|
||||
// internal error, leaving the modal open. The strength bar is hidden
|
||||
// until the module finishes loading, so wait for it to become visible.
|
||||
await page.waitForSelector("#pw_strength .bar", {visible: true});
|
||||
await page.click(change_password_button_selector);
|
||||
|
||||
// On success the change password modal gets closed.
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
}
|
||||
|
||||
async function test_get_api_key(page: Page): Promise<void> {
|
||||
await page.click('[data-section="account-and-privacy"]');
|
||||
const show_change_api_key_selector = "#api_key_button";
|
||||
await page.waitForSelector(show_change_api_key_selector, {visible: true});
|
||||
await page.click(show_change_api_key_selector);
|
||||
|
||||
const get_api_key_button_selector = "#get_api_key_button";
|
||||
await page.waitForSelector(get_api_key_button_selector, {visible: true});
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
await common.fill_form(page, "#api_key_form", {
|
||||
password: test_credentials.default_user.password,
|
||||
});
|
||||
|
||||
// When typing the password in Firefox, it shows "Not Secure" warning
|
||||
// which was hiding the Get API key button.
|
||||
// You can see the screenshot of it in https://github.com/zulip/zulip/pull/17136.
|
||||
// Focusing on it will remove the warning.
|
||||
await page.focus(get_api_key_button_selector);
|
||||
await page.click(get_api_key_button_selector);
|
||||
|
||||
await page.waitForSelector("#show_api_key", {visible: true});
|
||||
const api_key = await common.get_text_from_selector(page, "#api_key_value");
|
||||
assert.match(api_key, /[\dA-Za-z]{32}/, "Incorrect API key format.");
|
||||
|
||||
const download_zuliprc_selector = "#download_zuliprc";
|
||||
await page.click(download_zuliprc_selector);
|
||||
const zuliprc_decoded_url = await get_decoded_url_in_selector(page, download_zuliprc_selector);
|
||||
assert.match(zuliprc_decoded_url, zuliprc_regex, "Incorrect zuliprc file");
|
||||
await page.click("#api_key_modal .modal__close");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
}
|
||||
|
||||
async function test_webhook_bot_creation(page: Page): Promise<void> {
|
||||
await page.waitForSelector("#personal-bot-list .add-a-new-bot", {visible: true});
|
||||
await page.click("#personal-bot-list .add-a-new-bot");
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".dialog_heading"),
|
||||
"Add a new bot",
|
||||
"Unexpected title for deactivate user modal",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".micromodal .dialog_submit_button"),
|
||||
"Add",
|
||||
"Deactivate button has incorrect text.",
|
||||
);
|
||||
await common.fill_form(page, "#create_bot_form", {
|
||||
bot_name: "Bot 1",
|
||||
bot_short_name: "1",
|
||||
bot_type: OUTGOING_WEBHOOK_BOT_TYPE,
|
||||
payload_url: "http://hostname.example.com/bots/followup",
|
||||
});
|
||||
await page.click(".micromodal .dialog_submit_button");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
|
||||
// Wait for the bot to appear in the local data store, since the
|
||||
// bot_add event may not have been processed yet when the modal closes.
|
||||
await page.waitForFunction(
|
||||
(bot_name: string) => zulip_test.get_user_id_from_name(bot_name) !== undefined,
|
||||
{},
|
||||
"Bot 1",
|
||||
);
|
||||
const user_id = await common.get_user_id_from_name(page, "Bot 1");
|
||||
await open_manage_bot_tab(page, user_id!);
|
||||
|
||||
const outgoing_webhook_zuliprc_regex =
|
||||
/^data:application\/octet-stream;charset=utf-8,\[api]\nemail=.+\nkey=.+\nsite=.+\ntoken=.+\n$/;
|
||||
|
||||
const zuliprc_url_selector = `.micromodal .hidden-zuliprc-download`;
|
||||
const download_zuliprc_selector = `.download-bot-zuliprc`;
|
||||
|
||||
await page.waitForSelector(download_zuliprc_selector, {visible: true});
|
||||
await page.click(download_zuliprc_selector);
|
||||
|
||||
// Wait for API request to be complete by checking when the loading indicator
|
||||
// is shown and hidden.
|
||||
const download_zuliprc_loader_selector = ".download-bot-zuliprc .button-loading-indicator";
|
||||
await page.waitForSelector(download_zuliprc_loader_selector, {visible: true});
|
||||
await page.waitForSelector(download_zuliprc_loader_selector, {hidden: true});
|
||||
|
||||
const zuliprc_decoded_url = await get_decoded_url_in_selector(page, zuliprc_url_selector);
|
||||
assert.match(
|
||||
zuliprc_decoded_url,
|
||||
outgoing_webhook_zuliprc_regex,
|
||||
"Incorrect outgoing webhook bot zuliprc format",
|
||||
);
|
||||
await page.click(".micromodal .modal__close");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
}
|
||||
|
||||
async function test_normal_bot_creation(page: Page): Promise<void> {
|
||||
await page.waitForSelector("#personal-bot-list .add-a-new-bot", {visible: true});
|
||||
await page.click("#personal-bot-list .add-a-new-bot");
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".dialog_heading"),
|
||||
"Add a new bot",
|
||||
"Unexpected title for deactivate user modal",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".micromodal .dialog_submit_button span"),
|
||||
"Add",
|
||||
"Deactivate button has incorrect text.",
|
||||
);
|
||||
await common.fill_form(page, "#create_bot_form", {
|
||||
bot_name: "Bot 2",
|
||||
bot_short_name: "2",
|
||||
bot_type: GENERIC_BOT_TYPE,
|
||||
});
|
||||
await page.click(".micromodal .dialog_submit_button");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
|
||||
// Wait for the bot to appear in the local data store, since the
|
||||
// bot_add event may not have been processed yet when the modal closes.
|
||||
await page.waitForFunction(
|
||||
(bot_name: string) => zulip_test.get_user_id_from_name(bot_name) !== undefined,
|
||||
{},
|
||||
"Bot 2",
|
||||
);
|
||||
const user_id = await common.get_user_id_from_name(page, "Bot 2");
|
||||
await open_manage_bot_tab(page, user_id!);
|
||||
|
||||
const zuliprc_url_selector = `.micromodal .hidden-zuliprc-download`;
|
||||
const download_zuliprc_selector = `.download-bot-zuliprc`;
|
||||
|
||||
await page.waitForSelector(download_zuliprc_selector, {visible: true});
|
||||
await page.click(download_zuliprc_selector);
|
||||
|
||||
// Wait for API request to be complete by checking when the loading indicator
|
||||
// is shown and hidden.
|
||||
const download_zuliprc_loader_selector = ".download-bot-zuliprc .button-loading-indicator";
|
||||
await page.waitForSelector(download_zuliprc_loader_selector, {visible: true});
|
||||
await page.waitForSelector(download_zuliprc_loader_selector, {hidden: true});
|
||||
|
||||
const zuliprc_decoded_url = await get_decoded_url_in_selector(page, zuliprc_url_selector);
|
||||
assert.match(zuliprc_decoded_url, zuliprc_regex, "Incorrect zuliprc format for bot.");
|
||||
await page.click(".micromodal .modal__close");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
}
|
||||
|
||||
async function test_botserverrc(page: Page): Promise<void> {
|
||||
await page.waitForSelector("#personal-bot-list .download-botserverrc-file", {visible: true});
|
||||
await page.click("#personal-bot-list .download-botserverrc-file");
|
||||
await page.waitForSelector(
|
||||
'#personal-bot-list .hidden-botserverrc-download[href^="data:application"]',
|
||||
);
|
||||
const botserverrc_decoded_url = await get_decoded_url_in_selector(
|
||||
page,
|
||||
"#personal-bot-list .hidden-botserverrc-download",
|
||||
);
|
||||
const botserverrc_regex =
|
||||
/^data:application\/octet-stream;charset=utf-8,\[]\nemail=.+\nkey=.+\nsite=.+\ntoken=.+\n$/;
|
||||
assert.match(botserverrc_decoded_url, botserverrc_regex, "Incorrect botserverrc format.");
|
||||
}
|
||||
|
||||
// Disabled the below test due to non-deterministic failures.
|
||||
// The test often fails to close the modal, as does the
|
||||
// test_invalid_edit_bot_form above.
|
||||
// TODO: Debug this and re-enable with a fix.
|
||||
async function test_edit_bot_form(page: Page): Promise<void> {
|
||||
return;
|
||||
const bot1_email = "1-bot@zulip.testserver";
|
||||
const bot1_edit_button = `.open_edit_bot_form[data-email="${CSS.escape(bot1_email)}"]`;
|
||||
await page.click(bot1_edit_button);
|
||||
|
||||
const edit_form_selector = `#bot-edit-form[data-email="${CSS.escape(bot1_email)}"]`;
|
||||
await page.waitForSelector(edit_form_selector, {visible: true});
|
||||
assert.equal(
|
||||
await page.$eval(`${edit_form_selector} input[name=full_name]`, (el) => el.value),
|
||||
"Bot 1",
|
||||
);
|
||||
|
||||
await common.fill_form(page, edit_form_selector, {full_name: "Bot one"});
|
||||
const save_button_selector = "#user-profile-modal .dialog_submit_button";
|
||||
await page.click(save_button_selector);
|
||||
|
||||
// The form gets closed on saving. So, assert it's closed by waiting for it to be hidden.
|
||||
await page.waitForSelector("#edit_bot_modal", {hidden: true});
|
||||
|
||||
await page.waitForSelector(
|
||||
`xpath///*[${common.has_class_x(
|
||||
"open_edit_bot_form",
|
||||
)} and @data-email="${bot1_email}"]/ancestor::*[${common.has_class_x(
|
||||
"details",
|
||||
)}]/*[${common.has_class_x("name")} and text()="Bot one"]`,
|
||||
);
|
||||
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
}
|
||||
|
||||
// Disabled the below test due to non-deterministic failures.
|
||||
// The test often fails to close the modal.
|
||||
// TODO: Debug this and re-enable with a fix.
|
||||
async function test_invalid_edit_bot_form(page: Page): Promise<void> {
|
||||
return;
|
||||
const bot1_email = "1-bot@zulip.testserver";
|
||||
const bot1_edit_button = `.open_edit_bot_form[data-email="${CSS.escape(bot1_email)}"]`;
|
||||
await page.click(bot1_edit_button);
|
||||
|
||||
const edit_form_selector = `#bot-edit-form[data-email="${CSS.escape(bot1_email)}"]`;
|
||||
await page.waitForSelector(edit_form_selector, {visible: true});
|
||||
assert.equal(
|
||||
await page.$eval(`${edit_form_selector} input[name=full_name]`, (el) => el.value),
|
||||
"Bot one",
|
||||
);
|
||||
|
||||
await common.fill_form(page, edit_form_selector, {full_name: "Bot 2"});
|
||||
const save_button_selector = "#user-profile-modal .dialog_submit_button";
|
||||
await page.click(save_button_selector);
|
||||
|
||||
// The form should not get closed on saving. Errors should be visible on the form.
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
await page.waitForSelector("#dialog_error", {visible: true});
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, "#dialog_error"),
|
||||
"Failed: Name is already in use.",
|
||||
);
|
||||
|
||||
const cancel_button_selector = "#user-profile-modal .dialog_exit_button";
|
||||
await page.waitForFunction(
|
||||
(cancel_button_selector: string) =>
|
||||
!document.querySelector(cancel_button_selector)?.hasAttribute("disabled"),
|
||||
{},
|
||||
cancel_button_selector,
|
||||
);
|
||||
await page.click(cancel_button_selector);
|
||||
await page.waitForSelector(
|
||||
`xpath///*[${common.has_class_x(
|
||||
"open_edit_bot_form",
|
||||
)} and @data-email="${bot1_email}"]/ancestor::*[${common.has_class_x(
|
||||
"details",
|
||||
)}]/*[${common.has_class_x("name")} and text()="Bot one"]`,
|
||||
);
|
||||
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
}
|
||||
|
||||
async function test_your_bots_section(page: Page): Promise<void> {
|
||||
await page.click('.normal-settings-list [data-section="bots"]');
|
||||
await test_webhook_bot_creation(page);
|
||||
await test_normal_bot_creation(page);
|
||||
await test_botserverrc(page);
|
||||
await test_edit_bot_form(page);
|
||||
await test_invalid_edit_bot_form(page);
|
||||
}
|
||||
|
||||
const alert_word_status_banner_selector = ".alert-word-status-banner";
|
||||
|
||||
async function add_alert_word(page: Page, word: string): Promise<void> {
|
||||
await page.click("#open-add-alert-word-modal");
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
|
||||
await page.type("#add-alert-word-name", word);
|
||||
await page.click("#add-alert-word .dialog_submit_button");
|
||||
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
}
|
||||
|
||||
async function check_alert_word_added(page: Page, word: string): Promise<void> {
|
||||
const added_alert_word_selector = `.alert-word-item[data-word='${CSS.escape(word)}']`;
|
||||
await page.waitForSelector(added_alert_word_selector, {visible: true});
|
||||
}
|
||||
|
||||
async function get_alert_words_status_text(page: Page): Promise<string> {
|
||||
await page.waitForSelector(alert_word_status_banner_selector, {visible: true});
|
||||
const status_text = await common.get_text_from_selector(
|
||||
page,
|
||||
".alert-word-status-banner .banner-label",
|
||||
);
|
||||
return status_text;
|
||||
}
|
||||
|
||||
async function close_alert_words_status(page: Page): Promise<void> {
|
||||
const status_close_button = ".alert-word-status-banner .banner-close-button";
|
||||
await page.click(status_close_button);
|
||||
await page.waitForSelector(alert_word_status_banner_selector, {hidden: true});
|
||||
}
|
||||
|
||||
async function test_duplicate_alert_words_cannot_be_added(
|
||||
page: Page,
|
||||
duplicate_word: string,
|
||||
): Promise<void> {
|
||||
await page.click("#open-add-alert-word-modal");
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
|
||||
await page.type("#add-alert-word-name", duplicate_word);
|
||||
await page.click("#add-alert-word .dialog_submit_button");
|
||||
|
||||
const alert_word_status_selector = "#dialog_error";
|
||||
await page.waitForSelector(alert_word_status_selector, {visible: true});
|
||||
const status_text = await common.get_text_from_selector(page, alert_word_status_selector);
|
||||
assert.strictEqual(status_text, "Alert word already exists!");
|
||||
|
||||
await page.click("#add-alert-word .dialog_exit_button");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
}
|
||||
|
||||
async function delete_alert_word(page: Page, word: string): Promise<void> {
|
||||
const delete_button_selector = `tr[data-word="${CSS.escape(word)}"] .remove-alert-word`;
|
||||
await page.click(delete_button_selector);
|
||||
await page.waitForSelector(delete_button_selector, {hidden: true});
|
||||
}
|
||||
|
||||
async function test_alert_word_deletion(page: Page, word: string): Promise<void> {
|
||||
await delete_alert_word(page, word);
|
||||
const status_text = await get_alert_words_status_text(page);
|
||||
assert.strictEqual(status_text, `Alert word ${word} removed successfully!`);
|
||||
await close_alert_words_status(page);
|
||||
}
|
||||
|
||||
async function test_alert_words_section(page: Page): Promise<void> {
|
||||
await page.click('[data-section="alert-words"]');
|
||||
const word = "puppeteer";
|
||||
await add_alert_word(page, word);
|
||||
await check_alert_word_added(page, word);
|
||||
await test_duplicate_alert_words_cannot_be_added(page, word);
|
||||
await test_alert_word_deletion(page, word);
|
||||
}
|
||||
|
||||
async function change_language(page: Page, language_data_code: string): Promise<void> {
|
||||
await page.waitForSelector("#default_language_widget", {
|
||||
visible: true,
|
||||
});
|
||||
await page.click("#default_language_widget");
|
||||
await page.waitForSelector(".dropdown-list", {visible: true});
|
||||
const language_selector = `li[data-unique-id="${CSS.escape(language_data_code)}"]`;
|
||||
await page.click(language_selector);
|
||||
}
|
||||
|
||||
async function check_language_setting_status(page: Page): Promise<void> {
|
||||
await page.waitForSelector("#user-preferences .general-settings-status .reload_link", {
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function assert_language_changed_to_chinese(page: Page): Promise<void> {
|
||||
await page.waitForSelector("#default_language_widget", {
|
||||
visible: true,
|
||||
});
|
||||
const default_language = await common.get_text_from_selector(page, ".dropdown_widget_value");
|
||||
assert.match(
|
||||
default_language,
|
||||
/^中文(简体)/v,
|
||||
"Default language has not been changed to Chinese.",
|
||||
);
|
||||
}
|
||||
|
||||
async function test_i18n_language_precedence(page: Page): Promise<void> {
|
||||
const settings_url_for_german = "http://zulip.zulipdev.com:9981/de/#settings";
|
||||
await page.goto(settings_url_for_german);
|
||||
await page.waitForSelector("#settings-change-box", {visible: true});
|
||||
const page_language_code = await page.evaluate(() => document.documentElement.lang);
|
||||
assert.strictEqual(page_language_code, "de");
|
||||
}
|
||||
|
||||
async function test_default_language_setting(page: Page): Promise<void> {
|
||||
// Since the "Bots" section of Personal Settings redirects us to Organization Settings > Bots with the "Your Bots" tab preselected,
|
||||
// we need to switch back to the Personal Settings tab to proceed with further testing.
|
||||
await page.waitForSelector('.tab-switcher .ind-tab[data-tab-key="settings"]', {visible: true});
|
||||
await page.click('.tab-switcher .ind-tab[data-tab-key="settings"]');
|
||||
await page.waitForSelector("#settings_overlay_container", {visible: true});
|
||||
|
||||
const preferences_section = '[data-section="preferences"]';
|
||||
await page.click(preferences_section);
|
||||
|
||||
const chinese_language_data_code = "zh-hans";
|
||||
await change_language(page, chinese_language_data_code);
|
||||
// Check that the saved indicator appears
|
||||
await check_language_setting_status(page);
|
||||
await page.click(".reload_link");
|
||||
await page.waitForSelector("#default_language_widget", {
|
||||
visible: true,
|
||||
});
|
||||
await assert_language_changed_to_chinese(page);
|
||||
await test_i18n_language_precedence(page);
|
||||
await page.waitForSelector(preferences_section, {visible: true});
|
||||
await page.click(preferences_section);
|
||||
|
||||
// Change the language back to English so that subsequent tests pass.
|
||||
await change_language(page, "en");
|
||||
|
||||
// Check that the saved indicator appears
|
||||
await check_language_setting_status(page);
|
||||
await page.goto("http://zulip.zulipdev.com:9981/#settings"); // get back to normal language.
|
||||
await page.waitForSelector(preferences_section, {visible: true});
|
||||
await page.click(preferences_section);
|
||||
await page.waitForSelector("#user-preferences .general-settings-status", {
|
||||
visible: true,
|
||||
});
|
||||
await page.waitForSelector("#default_language_widget", {
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function test_notifications_section(page: Page): Promise<void> {
|
||||
await page.click('[data-section="notifications"]');
|
||||
// At the beginning, "DMs, mentions, and alerts"(checkbox name=enable_sounds) audio will be on
|
||||
// and "Streams"(checkbox name=enable_stream_audible_notifications) audio will be off by default.
|
||||
|
||||
const notification_sound_enabled =
|
||||
"#user-notification-settings .setting_notification_sound:enabled";
|
||||
await page.waitForSelector(notification_sound_enabled, {visible: true});
|
||||
|
||||
await common.fill_form(page, "#user-notification-settings .notification-settings-form", {
|
||||
enable_stream_audible_notifications: true,
|
||||
enable_sounds: false,
|
||||
});
|
||||
await page.waitForSelector(notification_sound_enabled, {visible: true});
|
||||
|
||||
await common.fill_form(page, "#user-notification-settings .notification-settings-form", {
|
||||
enable_stream_audible_notifications: true,
|
||||
});
|
||||
/*
|
||||
Usually notifications sound dropdown gets disabled on disabling
|
||||
all audio notifications. But this seems flaky in tests.
|
||||
TODO: Find the right fix and enable this.
|
||||
|
||||
const notification_sound_disabled = ".setting_notification_sound:disabled";
|
||||
await page.waitForSelector(notification_sound_disabled);
|
||||
*/
|
||||
}
|
||||
|
||||
async function settings_tests(page: Page): Promise<void> {
|
||||
await common.log_in(page);
|
||||
await open_settings(page);
|
||||
await close_settings_and_date_picker(page);
|
||||
await open_settings(page);
|
||||
await test_change_full_name(page);
|
||||
await test_alert_words_section(page);
|
||||
await test_your_bots_section(page);
|
||||
await test_default_language_setting(page);
|
||||
await test_notifications_section(page);
|
||||
await test_get_api_key(page);
|
||||
await test_change_password(page);
|
||||
// test_change_password should be the very last test, because it
|
||||
// replaces your session, which can lead to some nondeterministic
|
||||
// failures in test code after it, involving `GET /events`
|
||||
// returning a 401. (We reset the test database after each file).
|
||||
}
|
||||
|
||||
await common.run_test(settings_tests);
|
||||
86
web/e2e-tests/stars.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {Page} from "puppeteer";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
|
||||
const message = "test star";
|
||||
|
||||
async function stars_count(page: Page): Promise<number> {
|
||||
return (await page.$$(".message-list .zulip-icon-star-filled:not(.empty-star)")).length;
|
||||
}
|
||||
|
||||
async function toggle_test_star_message(page: Page): Promise<void> {
|
||||
const messagebox = await page.waitForSelector(
|
||||
`xpath/(//*[${common.has_class_x("message-list")}]//*[${common.has_class_x(
|
||||
"message_content",
|
||||
)} and normalize-space()="${message}"])[last()]/ancestor::*[${common.has_class_x(
|
||||
"messagebox",
|
||||
)}]`,
|
||||
{visible: true},
|
||||
);
|
||||
assert.ok(messagebox !== null);
|
||||
await messagebox.hover();
|
||||
|
||||
const star_icon = await messagebox.waitForSelector(".star", {visible: true});
|
||||
assert.ok(star_icon !== null);
|
||||
await star_icon.click();
|
||||
}
|
||||
|
||||
async function test_narrow_to_starred_messages(page: Page): Promise<void> {
|
||||
await page.click('#left-sidebar-navigation-list a[href^="#narrow/is/starred"]');
|
||||
const message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await common.check_messages_sent(page, message_list_id, [["Verona > stars", [message]]]);
|
||||
|
||||
// Go back to the combined feed view.
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
const combined_feed_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(
|
||||
`.message-list[data-message-list-id='${combined_feed_id}'] .message_row`,
|
||||
{visible: true},
|
||||
);
|
||||
}
|
||||
|
||||
async function stars_test(page: Page): Promise<void> {
|
||||
await common.log_in(page);
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
let message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(
|
||||
`.message-list[data-message-list-id='${message_list_id}'] .message_row`,
|
||||
{visible: true},
|
||||
);
|
||||
// Assert that there is only one message list.
|
||||
assert.equal((await page.$$(".message-list")).length, 1);
|
||||
await common.send_message(page, "stream", {
|
||||
stream_name: "Verona",
|
||||
topic: "stars",
|
||||
content: message,
|
||||
});
|
||||
|
||||
assert.strictEqual(await stars_count(page), 0, "Unexpected already starred message(s).");
|
||||
|
||||
await toggle_test_star_message(page);
|
||||
await page.click("#left-sidebar-navigation-list .top_left_all_messages");
|
||||
message_list_id = await common.get_current_msg_list_id(page, true);
|
||||
await page.waitForSelector(
|
||||
`.message-list[data-message-list-id='${message_list_id}'] .zulip-icon-star-filled`,
|
||||
{visible: true},
|
||||
);
|
||||
assert.strictEqual(
|
||||
await stars_count(page),
|
||||
1,
|
||||
"Failed to ensure 1 starred message after change.",
|
||||
);
|
||||
|
||||
await test_narrow_to_starred_messages(page);
|
||||
assert.strictEqual(
|
||||
await stars_count(page),
|
||||
1,
|
||||
"Message star disappeared after switching views.",
|
||||
);
|
||||
|
||||
await toggle_test_star_message(page);
|
||||
assert.strictEqual(await stars_count(page), 0, "Message was not unstarred correctly.");
|
||||
}
|
||||
|
||||
await common.run_test(stars_test);
|
||||
191
web/e2e-tests/stream_create.test.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {Page} from "puppeteer";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
|
||||
async function user_row_selector(page: Page, name: string): Promise<string> {
|
||||
const user_id = await common.get_user_id_from_name(page, name);
|
||||
const selector = `.settings-subscriber-row[data-user-id="${user_id}"]`;
|
||||
return selector;
|
||||
}
|
||||
|
||||
async function await_user_visible(page: Page, name: string): Promise<void> {
|
||||
const selector = await user_row_selector(page, name);
|
||||
await page.waitForSelector(selector, {visible: true});
|
||||
}
|
||||
|
||||
async function await_user_hidden(page: Page, name: string): Promise<void> {
|
||||
const selector = await user_row_selector(page, name);
|
||||
await page.waitForSelector(selector, {hidden: true});
|
||||
}
|
||||
|
||||
async function add_user_to_stream(page: Page, name: string): Promise<void> {
|
||||
const user_id = await common.get_user_id_from_name(page, name);
|
||||
assert.ok(user_id !== undefined);
|
||||
await page.evaluate((user_id) => {
|
||||
zulip_test.add_user_id_to_new_stream(user_id);
|
||||
}, user_id);
|
||||
await await_user_visible(page, name);
|
||||
}
|
||||
|
||||
async function stream_name_error(page: Page): Promise<string> {
|
||||
await page.waitForSelector("#stream_name_error", {visible: true});
|
||||
return await common.get_text_from_selector(page, "#stream_name_error");
|
||||
}
|
||||
|
||||
async function click_create_new_stream(page: Page): Promise<void> {
|
||||
await page.click("#add_new_subscription .create_stream_button");
|
||||
await page.type("#create_stream_name", "Test Stream 1");
|
||||
await page.click("#stream_creation_go_to_subscribers");
|
||||
await page.waitForSelector(".finalize_create_stream", {visible: true});
|
||||
|
||||
// sanity check that desdemona is the initial subsscriber
|
||||
await await_user_visible(page, "desdemona");
|
||||
}
|
||||
|
||||
async function clear_ot_filter_with_backspace(page: Page): Promise<void> {
|
||||
await page.click("#people_to_add input.search");
|
||||
await page.keyboard.press("Backspace");
|
||||
await page.keyboard.press("Backspace");
|
||||
}
|
||||
|
||||
async function test_user_filter_ui(page: Page): Promise<void> {
|
||||
await page.waitForSelector("form#stream_creation_form", {visible: true});
|
||||
// Desdemona should be there by default
|
||||
await await_user_visible(page, "desdemona");
|
||||
|
||||
await add_user_to_stream(page, common.fullname.cordelia);
|
||||
await add_user_to_stream(page, common.fullname.othello);
|
||||
|
||||
await page.type(`#people_to_add input.search`, "ot", {delay: 100});
|
||||
await page.waitForSelector("#create_stream_subscribers", {visible: true});
|
||||
// Wait until filtering is completed.
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelectorAll("#create_stream_subscribers .remove_potential_subscriber")
|
||||
.length === 1,
|
||||
);
|
||||
|
||||
await await_user_hidden(page, common.fullname.cordelia);
|
||||
await await_user_hidden(page, "desdemona");
|
||||
await await_user_visible(page, common.fullname.othello);
|
||||
|
||||
// Clear the filter.
|
||||
await clear_ot_filter_with_backspace(page);
|
||||
|
||||
await await_user_visible(page, common.fullname.cordelia);
|
||||
await await_user_visible(page, "desdemona");
|
||||
await await_user_visible(page, common.fullname.othello);
|
||||
}
|
||||
|
||||
async function create_stream(page: Page): Promise<void> {
|
||||
await page.click("#stream_creation_go_to_configure_channel_settings");
|
||||
await page.waitForSelector('xpath///*[text()="Configure new channel settings"]', {
|
||||
visible: true,
|
||||
});
|
||||
|
||||
await common.fill_form(page, "form#stream_creation_form", {
|
||||
stream_name: "Puppeteer",
|
||||
stream_description: "Everything Puppeteer",
|
||||
});
|
||||
await page.click("#stream_creation_go_to_subscribers");
|
||||
await page.type("#create_stream_name", "Test Stream 2");
|
||||
await page.click("form#stream_creation_form .finalize_create_stream");
|
||||
|
||||
// We redirect to the channel message view.
|
||||
await page.waitForSelector("#subscription_overlay", {hidden: true});
|
||||
await page.waitForSelector(
|
||||
`xpath///*[${common.has_class_x("message-header-navbar-title")} and text()="Puppeteer"]`,
|
||||
);
|
||||
|
||||
await page.waitForSelector(".message-header-stream-settings-button");
|
||||
await page.click(".message-header-stream-settings-button");
|
||||
await page.waitForSelector(".stream_section");
|
||||
await page.waitForSelector(
|
||||
`xpath///*[${common.has_class_x("stream-name-title")} and text()="Puppeteer"]`,
|
||||
{visible: true},
|
||||
);
|
||||
const stream_name = await common.get_text_from_selector(
|
||||
page,
|
||||
".selected-stream-title .stream-name-title",
|
||||
);
|
||||
const stream_description = await common.get_text_from_selector(
|
||||
page,
|
||||
".stream-description .sub-stream-description",
|
||||
);
|
||||
assert.strictEqual(stream_name, "Puppeteer");
|
||||
assert.strictEqual(stream_description, "Everything Puppeteer");
|
||||
|
||||
// Assert subscriber count becomes 3 (cordelia, desdemona, othello)
|
||||
await page.waitForSelector(
|
||||
`xpath///*[@data-stream-name="Puppeteer"]//*[${common.has_class_x(
|
||||
"subscriber-count",
|
||||
)} and normalize-space()="3"]`,
|
||||
);
|
||||
}
|
||||
|
||||
async function test_streams_with_empty_names_cannot_be_created(page: Page): Promise<void> {
|
||||
await page.click("#add_new_subscription .create_stream_button");
|
||||
await page.waitForSelector("form#stream_creation_form", {visible: true});
|
||||
await common.fill_form(page, "form#stream_creation_form", {stream_name: " "});
|
||||
await page.click("form#stream_creation_form button#stream_creation_go_to_subscribers");
|
||||
assert.strictEqual(await stream_name_error(page), "Choose a name for the new channel.");
|
||||
}
|
||||
|
||||
async function test_streams_with_duplicate_names_cannot_be_created(page: Page): Promise<void> {
|
||||
await common.fill_form(page, "form#stream_creation_form", {stream_name: "Puppeteer"});
|
||||
await page.click("form#stream_creation_form button#stream_creation_go_to_subscribers");
|
||||
assert.strictEqual(await stream_name_error(page), "A channel with this name already exists.");
|
||||
|
||||
const cancel_button_selector = "form#stream_creation_form button.create_stream_cancel";
|
||||
await page.click(cancel_button_selector);
|
||||
}
|
||||
|
||||
async function test_stream_creation(page: Page): Promise<void> {
|
||||
await click_create_new_stream(page);
|
||||
await test_user_filter_ui(page);
|
||||
await create_stream(page);
|
||||
await test_streams_with_empty_names_cannot_be_created(page);
|
||||
await test_streams_with_duplicate_names_cannot_be_created(page);
|
||||
}
|
||||
|
||||
async function test_streams_search_feature(page: Page): Promise<void> {
|
||||
assert.strictEqual(await common.get_text_from_selector(page, "#search_stream_name"), "");
|
||||
const hidden_streams_selector = ".stream-row.notdisplayed .stream-name";
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(
|
||||
page,
|
||||
'.stream-row[data-stream-name="Verona"] .stream-name',
|
||||
),
|
||||
"Verona",
|
||||
);
|
||||
assert.ok(
|
||||
!(await common.get_text_from_selector(page, hidden_streams_selector)).includes("Verona"),
|
||||
"#Verona is hidden",
|
||||
);
|
||||
|
||||
await page.type('#stream_filter input[type="text"]', "Puppeteer");
|
||||
await page.waitForSelector(".stream-row[data-stream-name='core team']", {hidden: true});
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".stream-row:not(.notdisplayed) .stream-name"),
|
||||
"Puppeteer",
|
||||
);
|
||||
assert.ok(
|
||||
(await common.get_text_from_selector(page, hidden_streams_selector)).includes("Verona"),
|
||||
"#Verona is not hidden",
|
||||
);
|
||||
assert.ok(
|
||||
!(await common.get_text_from_selector(page, hidden_streams_selector)).includes("Puppeteer"),
|
||||
"Puppeteer is hidden after searching.",
|
||||
);
|
||||
}
|
||||
|
||||
async function subscriptions_tests(page: Page): Promise<void> {
|
||||
await common.log_in(page);
|
||||
await common.open_streams_modal(page);
|
||||
await test_stream_creation(page);
|
||||
await test_streams_search_feature(page);
|
||||
}
|
||||
|
||||
await common.run_test(subscriptions_tests);
|
||||
57
web/e2e-tests/subscribe_toggle.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type {ElementHandle, Page} from "puppeteer";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
|
||||
async function test_subscription_button(page: Page): Promise<void> {
|
||||
const all_stream_selector = "[data-tab-key='all']";
|
||||
await page.waitForSelector(all_stream_selector, {visible: true});
|
||||
await page.click(all_stream_selector);
|
||||
const stream_selector = "[data-stream-name='Venice']";
|
||||
const button_selector = `${stream_selector} .sub_unsub_button`;
|
||||
const subscribed_selector = `${button_selector}.checked`;
|
||||
const unsubscribed_selector = `${button_selector}:not(.checked)`;
|
||||
|
||||
async function subscribed(): Promise<ElementHandle | null> {
|
||||
await page.waitForSelector(
|
||||
`xpath///*[${common.has_class_x("stream-info-title")}]//*[${common.has_class_x(
|
||||
"sub_unsub_button",
|
||||
)} and normalize-space()="Unsubscribe"]`,
|
||||
{visible: true},
|
||||
);
|
||||
return await page.waitForSelector(subscribed_selector, {visible: true});
|
||||
}
|
||||
|
||||
async function unsubscribed(): Promise<ElementHandle | null> {
|
||||
await page.waitForSelector(
|
||||
`xpath///*[${common.has_class_x("stream-info-title")}]//*[${common.has_class_x(
|
||||
"sub_unsub_button",
|
||||
)} and normalize-space()="Subscribe"]`,
|
||||
{visible: true},
|
||||
);
|
||||
return await page.waitForSelector(unsubscribed_selector, {visible: true});
|
||||
}
|
||||
|
||||
// Make sure that Venice is even in our list of streams.
|
||||
await page.waitForSelector(stream_selector, {visible: true});
|
||||
await page.waitForSelector(button_selector, {visible: true});
|
||||
|
||||
await page.click(stream_selector);
|
||||
|
||||
// Note that we intentionally re-find the button after each click, since
|
||||
// the live-update code may replace the whole row.
|
||||
// We assume Venice is already subscribed, so the first line here
|
||||
// should happen immediately.
|
||||
await (await subscribed())!.click();
|
||||
await (await unsubscribed())!.click();
|
||||
await (await subscribed())!.click();
|
||||
await (await unsubscribed())!.click();
|
||||
await subscribed();
|
||||
}
|
||||
|
||||
async function subscriptions_tests(page: Page): Promise<void> {
|
||||
await common.log_in(page);
|
||||
await common.open_streams_modal(page);
|
||||
await test_subscription_button(page);
|
||||
}
|
||||
|
||||
await common.run_test(subscriptions_tests);
|
||||
153
web/e2e-tests/user-deactivation.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {Page} from "puppeteer";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
|
||||
async function navigate_to_user_list(page: Page): Promise<void> {
|
||||
const menu_selector = "#settings-dropdown";
|
||||
await page.waitForSelector(menu_selector, {visible: true});
|
||||
await page.click(menu_selector);
|
||||
|
||||
const organization_settings = '.link-item a[href="#organization"]';
|
||||
await page.waitForSelector(organization_settings, {visible: true});
|
||||
await page.click(organization_settings);
|
||||
|
||||
await page.waitForSelector("#settings_overlay_container.show", {visible: true});
|
||||
await page.waitForSelector("li[data-section='users']", {visible: true});
|
||||
await page.click("li[data-section='users']");
|
||||
await page.waitForSelector("#admin-user-list.show", {visible: true});
|
||||
}
|
||||
|
||||
async function user_row(page: Page, name: string): Promise<string> {
|
||||
const user_id = await common.get_user_id_from_name(page, name);
|
||||
assert.ok(user_id !== undefined);
|
||||
return `.user_row[data-user-id="${CSS.escape(user_id.toString())}"]`;
|
||||
}
|
||||
|
||||
async function test_reactivation_confirmation_modal(page: Page, fullname: string): Promise<void> {
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".dialog_heading"),
|
||||
"Reactivate " + fullname,
|
||||
"Unexpected title for reactivate user modal",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".micromodal .dialog_submit_button"),
|
||||
"Confirm",
|
||||
"Reactivate button has incorrect text.",
|
||||
);
|
||||
await page.click(".micromodal .dialog_submit_button");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
}
|
||||
|
||||
async function test_deactivate_user(page: Page): Promise<void> {
|
||||
const cordelia_user_row = await user_row(page, common.fullname.cordelia);
|
||||
await page.waitForSelector(cordelia_user_row, {visible: true});
|
||||
// Wait for the presence-data fetch in `settings_users.populate_users`
|
||||
// to complete and the table to re-render.
|
||||
await page.waitForSelector(cordelia_user_row + " .loading-placeholder", {hidden: true});
|
||||
await page.waitForSelector(cordelia_user_row + " .zulip-icon-user-x", {visible: true});
|
||||
await page.click(cordelia_user_row + " .deactivate");
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".dialog_heading"),
|
||||
"Deactivate " + common.fullname.cordelia + "?",
|
||||
"Unexpected title for deactivate user modal",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".micromodal .dialog_submit_button"),
|
||||
"Deactivate",
|
||||
"Deactivate button has incorrect text.",
|
||||
);
|
||||
await page.click(".micromodal .dialog_submit_button");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
}
|
||||
|
||||
async function test_reactivate_user(page: Page): Promise<void> {
|
||||
let cordelia_user_row = await user_row(page, common.fullname.cordelia);
|
||||
await page.waitForSelector(cordelia_user_row + ".deactivated_user");
|
||||
await page.waitForSelector(cordelia_user_row + " .zulip-icon-user-plus", {visible: true});
|
||||
await page.click(cordelia_user_row + " .reactivate");
|
||||
|
||||
await test_reactivation_confirmation_modal(page, common.fullname.cordelia);
|
||||
|
||||
await page.waitForSelector(cordelia_user_row + ":not(.deactivated_user)", {visible: true});
|
||||
cordelia_user_row = await user_row(page, common.fullname.cordelia);
|
||||
await page.waitForSelector(cordelia_user_row + " .zulip-icon-user-x", {visible: true});
|
||||
}
|
||||
|
||||
async function test_deactivated_users_section(page: Page): Promise<void> {
|
||||
const cordelia_user_row = await user_row(page, common.fullname.cordelia);
|
||||
await test_deactivate_user(page);
|
||||
|
||||
// "Deactivated users" section doesn't render just deactivated users until reloaded.
|
||||
await page.reload();
|
||||
await page.waitForSelector("#admin-user-list.show", {visible: true});
|
||||
const deactivated_users_section = ".tab-container .ind-tab[data-tab-key='deactivated']";
|
||||
await page.waitForSelector(deactivated_users_section, {visible: true});
|
||||
await page.click(deactivated_users_section);
|
||||
|
||||
// Instead of waiting for reactivate button using the `waitForSelector` function,
|
||||
// we wait until the input is focused because the `waitForSelector` function
|
||||
// doesn't guarantee that element is interactable.
|
||||
await page.waitForSelector("input[aria-label='Filter deactivated users']", {visible: true});
|
||||
await page.click("input[aria-label='Filter deactivated users']");
|
||||
await page.waitForFunction(
|
||||
() => document.activeElement?.classList?.contains("search") === true,
|
||||
);
|
||||
await page.click("#admin_deactivated_users_table " + cordelia_user_row + " .reactivate");
|
||||
|
||||
await test_reactivation_confirmation_modal(page, common.fullname.cordelia);
|
||||
|
||||
await page.waitForSelector(
|
||||
"#admin_deactivated_users_table " + cordelia_user_row + " button:not(.reactivate)",
|
||||
{visible: true},
|
||||
);
|
||||
}
|
||||
|
||||
async function test_bot_deactivation_and_reactivation(page: Page): Promise<void> {
|
||||
await page.waitForSelector(".org-settings-list li[data-section='bots']", {visible: true});
|
||||
await page.click(".org-settings-list li[data-section='bots']");
|
||||
await page.waitForSelector("#admin-bot-list.show", {visible: true});
|
||||
|
||||
const default_bot_user_row = await user_row(page, "Zulip Default Bot");
|
||||
|
||||
await page.waitForSelector(default_bot_user_row + " .deactivate", {visible: true});
|
||||
await page.click(default_bot_user_row + " .deactivate");
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".dialog_heading"),
|
||||
"Deactivate Zulip Default Bot?",
|
||||
"Unexpected title for deactivate bot modal",
|
||||
);
|
||||
assert.strictEqual(
|
||||
await common.get_text_from_selector(page, ".micromodal .dialog_submit_button"),
|
||||
"Deactivate",
|
||||
"Deactivate button has incorrect text.",
|
||||
);
|
||||
await page.click(".micromodal .dialog_submit_button");
|
||||
await common.wait_for_micromodal_to_close(page);
|
||||
|
||||
await page.waitForSelector(default_bot_user_row + ".deactivated_user", {visible: true});
|
||||
await page.waitForSelector(default_bot_user_row + " .zulip-icon-user-plus", {visible: true});
|
||||
|
||||
await page.click(default_bot_user_row + " .reactivate");
|
||||
await test_reactivation_confirmation_modal(page, "Zulip Default Bot");
|
||||
await page.waitForSelector(default_bot_user_row + ":not(.deactivated_user)", {visible: true});
|
||||
await page.waitForSelector(default_bot_user_row + " .zulip-icon-user-x", {visible: true});
|
||||
}
|
||||
|
||||
async function user_deactivation_test(page: Page): Promise<void> {
|
||||
await common.log_in(page);
|
||||
await navigate_to_user_list(page);
|
||||
await test_deactivate_user(page);
|
||||
await test_reactivate_user(page);
|
||||
await test_deactivated_users_section(page);
|
||||
await test_bot_deactivation_and_reactivation(page);
|
||||
}
|
||||
|
||||
await common.run_test(user_deactivation_test);
|
||||
58
web/e2e-tests/user-status.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type {Page} from "puppeteer";
|
||||
|
||||
import * as common from "./lib/common.ts";
|
||||
|
||||
async function open_set_user_status_modal(page: Page): Promise<void> {
|
||||
await page.click("#personal-menu");
|
||||
await page.waitForSelector("#personal-menu-dropdown", {visible: true});
|
||||
// We are using evaluate to click because it is very hard to detect if
|
||||
// the personal menu popover has opened.
|
||||
await page.evaluate(() => {
|
||||
document.querySelector<HTMLAnchorElement>(".update_status_text")!.click();
|
||||
});
|
||||
|
||||
// Wait for the modal to completely open.
|
||||
await common.wait_for_micromodal_to_open(page);
|
||||
}
|
||||
|
||||
async function test_user_status(page: Page): Promise<void> {
|
||||
await open_set_user_status_modal(page);
|
||||
// Check by clicking on common statues.
|
||||
await page.click(".user-status-option:nth-child(2) .user-status-value");
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector<HTMLInputElement>(".user-status")!.value === "In a meeting",
|
||||
);
|
||||
// It should select calendar emoji.
|
||||
await page.waitForSelector(".selected-emoji.emoji-1f4c5");
|
||||
|
||||
// Clear everything.
|
||||
await page.click("#clear_status_message_button");
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector<HTMLInputElement>(".user-status")!.value === "",
|
||||
);
|
||||
await page.waitForSelector(".status-emoji-wrapper .smiley-icon", {visible: true});
|
||||
|
||||
// Manually adding everything.
|
||||
await page.type(".user-status", "Busy");
|
||||
const laughing_emoji_selector = ".emoji-1f606";
|
||||
await page.click(".status-emoji-wrapper .smiley-icon");
|
||||
// Wait until emoji popover is opened.
|
||||
await page.waitForSelector(`.emoji-popover ${laughing_emoji_selector}`, {visible: true});
|
||||
await page.click(`.emoji-popover ${laughing_emoji_selector}`);
|
||||
await page.waitForSelector(".emoji-picker-popover", {hidden: true});
|
||||
await page.waitForSelector(`.selected-emoji${laughing_emoji_selector}`);
|
||||
|
||||
await page.click("#set-user-status-modal .dialog_submit_button");
|
||||
// It should close the modal after saving.
|
||||
await page.waitForSelector("#set-user-status-modal", {hidden: true});
|
||||
|
||||
// Check if the emoji is added in user presence list.
|
||||
await page.waitForSelector(`.user-presence-link .status-emoji${laughing_emoji_selector}`);
|
||||
}
|
||||
|
||||
async function user_status_test(page: Page): Promise<void> {
|
||||
await common.log_in(page);
|
||||
await test_user_status(page);
|
||||
}
|
||||
|
||||
await common.run_test(user_status_test);
|
||||
1
web/generated/README.md
Normal file
@@ -0,0 +1 @@
|
||||
This directory is for generated frontend assets.
|
||||
42
web/html/5xx-cloud.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>500 internal server error | Zulip</title>
|
||||
<meta http-equiv="refresh" content="60;URL='/'" />
|
||||
<!-- We use `error-styles` webpack assets to styles this page. -->
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-main" id="top_navbar">
|
||||
<div class="float-left">
|
||||
<a class="brand logo" href="https://zulip.com/">
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="68.96 55.62 1742.12 450.43" height="25">
|
||||
<path fill="hsl(0, 0%, 27%)" d="M473.09 122.97c0 22.69-10.19 42.85-25.72 55.08L296.61 312.69c-2.8 2.4-6.44-1.47-4.42-4.7l55.3-110.72c1.55-3.1-.46-6.91-3.64-6.91H129.36c-33.22 0-60.4-30.32-60.4-67.37 0-37.06 27.18-67.37 60.4-67.37h283.33c33.22-.02 60.4 30.3 60.4 67.35zM129.36 506.05h283.33c33.22 0 60.4-30.32 60.4-67.37 0-37.06-27.18-67.37-60.4-67.37H198.2c-3.18 0-5.19-3.81-3.64-6.91l55.3-110.72c2.02-3.23-1.62-7.1-4.42-4.7L94.68 383.6c-15.53 12.22-25.72 32.39-25.72 55.08 0 37.05 27.18 67.37 60.4 67.37zm522.5-124.15l124.78-179.6v-1.56H663.52v-48.98h190.09v34.21L731.55 363.24v1.56h124.01v48.98h-203.7V381.9zm338.98-230.14V302.6c0 45.09 17.1 68.03 47.43 68.03 31.1 0 48.2-21.77 48.2-68.03V151.76h59.09V298.7c0 80.86-40.82 119.34-109.24 119.34-66.09 0-104.96-36.54-104.96-120.12V151.76h59.48zm244.91 0h59.48v212.25h104.18v49.76h-163.66V151.76zm297 0v262.01h-59.48V151.76h59.48zm90.18 3.5c18.27-3.11 43.93-5.44 80.08-5.44 36.54 0 62.59 7 80.08 20.99 16.72 13.22 27.99 34.99 27.99 60.64 0 25.66-8.55 47.43-24.1 62.2-20.21 19.05-50.15 27.6-85.13 27.6-7.77 0-14.77-.39-20.21-1.17v93.69h-58.7V155.26zm58.7 118.96c5.05 1.17 11.27 1.55 19.83 1.55 31.49 0 50.92-15.94 50.92-42.76 0-24.1-16.72-38.49-46.26-38.49-12.05 0-20.21 1.17-24.49 2.33v77.37z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="error_page">
|
||||
<img src="/static/images/errors/500art.svg" alt=""/>
|
||||
<div class="errorbox">
|
||||
<div class="errorcontent">
|
||||
<!-- Keep in sync with templates/500.html -->
|
||||
<h1 class="lead">Internal server error</h1>
|
||||
<p>
|
||||
Something went wrong. Sorry about that! We're
|
||||
aware of the problem and are working to fix
|
||||
it. Zulip will load automatically once it is
|
||||
working again.
|
||||
</p>
|
||||
<p>
|
||||
Please check <a href="https://status.zulip.com/">Zulip Cloud status</a> for
|
||||
more information, and <a href="mailto:support@zulip.com">contact
|
||||
Zulip support</a> with any questions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
38
web/html/5xx.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>500 internal server error | Zulip</title>
|
||||
<meta http-equiv="refresh" content="60;URL='/'" />
|
||||
<!-- We use `error-styles` webpack assets to styles this page. -->
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-main" id="top_navbar">
|
||||
<div class="float-left">
|
||||
<a class="brand logo" href="https://zulip.com/">
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="68.96 55.62 1742.12 450.43" height="25">
|
||||
<path fill="hsl(0, 0%, 27%)" d="M473.09 122.97c0 22.69-10.19 42.85-25.72 55.08L296.61 312.69c-2.8 2.4-6.44-1.47-4.42-4.7l55.3-110.72c1.55-3.1-.46-6.91-3.64-6.91H129.36c-33.22 0-60.4-30.32-60.4-67.37 0-37.06 27.18-67.37 60.4-67.37h283.33c33.22-.02 60.4 30.3 60.4 67.35zM129.36 506.05h283.33c33.22 0 60.4-30.32 60.4-67.37 0-37.06-27.18-67.37-60.4-67.37H198.2c-3.18 0-5.19-3.81-3.64-6.91l55.3-110.72c2.02-3.23-1.62-7.1-4.42-4.7L94.68 383.6c-15.53 12.22-25.72 32.39-25.72 55.08 0 37.05 27.18 67.37 60.4 67.37zm522.5-124.15l124.78-179.6v-1.56H663.52v-48.98h190.09v34.21L731.55 363.24v1.56h124.01v48.98h-203.7V381.9zm338.98-230.14V302.6c0 45.09 17.1 68.03 47.43 68.03 31.1 0 48.2-21.77 48.2-68.03V151.76h59.09V298.7c0 80.86-40.82 119.34-109.24 119.34-66.09 0-104.96-36.54-104.96-120.12V151.76h59.48zm244.91 0h59.48v212.25h104.18v49.76h-163.66V151.76zm297 0v262.01h-59.48V151.76h59.48zm90.18 3.5c18.27-3.11 43.93-5.44 80.08-5.44 36.54 0 62.59 7 80.08 20.99 16.72 13.22 27.99 34.99 27.99 60.64 0 25.66-8.55 47.43-24.1 62.2-20.21 19.05-50.15 27.6-85.13 27.6-7.77 0-14.77-.39-20.21-1.17v93.69h-58.7V155.26zm58.7 118.96c5.05 1.17 11.27 1.55 19.83 1.55 31.49 0 50.92-15.94 50.92-42.76 0-24.1-16.72-38.49-46.26-38.49-12.05 0-20.21 1.17-24.49 2.33v77.37z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="error_page">
|
||||
<img src="/static/images/errors/500art.svg" alt=""/>
|
||||
<div class="errorbox">
|
||||
<div class="errorcontent">
|
||||
<!-- Keep in sync with templates/500.html -->
|
||||
<h1 class="lead">Internal server error</h1>
|
||||
<p>
|
||||
Something went wrong. Sorry about that!
|
||||
Zulip will load automatically once it is working again.
|
||||
</p>
|
||||
<p>If you administer this server, you may want to check out the
|
||||
<a href="https://zulip.readthedocs.io/en/stable/production/troubleshooting.html">Zulip server troubleshooting guide</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
58
web/icons/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
This directory contains icons used by the Zulip web app, primarily
|
||||
sourced from Lucide Icons and Feather Icons (see `docs/THIRDPARTY).
|
||||
|
||||
Icons placed in this directory are compiled by the web application
|
||||
build system into a custom icon font. You can use them in HTML using
|
||||
the following syntax:
|
||||
|
||||
`<i class="zulip-icon zulip-icon-more-vertical">`
|
||||
|
||||
It is critical that icons from third parties be listed with appropriate
|
||||
attribution in the `docs/THIRDPARTY` file. To make it easier to audit
|
||||
that file in the future, below is a list of custom icons created in-house
|
||||
for the Zulip project:
|
||||
|
||||
arrow-down.svg
|
||||
arrow-down-left.svg
|
||||
arrow-up-right.svg
|
||||
bold.svg
|
||||
bot.svg
|
||||
browse-channels.svg
|
||||
compose-scroll-left.svg
|
||||
compose-scroll-right.svg
|
||||
dm-groups-3.svg
|
||||
exclamation-circle.svg
|
||||
gif.svg
|
||||
hashtag.svg
|
||||
heading-triangle-right.svg
|
||||
help-bigger.svg
|
||||
help.svg
|
||||
keyboard.svg
|
||||
line-height-big.svg
|
||||
link.svg
|
||||
manage-search.svg
|
||||
masked-unread.svg
|
||||
math.svg
|
||||
ordered-list.svg
|
||||
panel-left-dashed.svg
|
||||
panel-left.svg
|
||||
pin.svg
|
||||
placeholder.svg
|
||||
question.svg
|
||||
quote-message.svg
|
||||
quote.svg
|
||||
search-inbox.svg
|
||||
send.svg
|
||||
sort-arrow-down.svg
|
||||
spoiler.svg
|
||||
strikethrough.svg
|
||||
topic-list.svg
|
||||
topic.svg
|
||||
type-big.svg
|
||||
unordered-list.svg
|
||||
unpin.svg
|
||||
unread.svg
|
||||
user-circle-active.svg
|
||||
user-circle-deactivated.svg
|
||||
user-circle-idle.svg
|
||||
user-circle-offline.svg
|
||||
1
web/icons/account.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><g><path d="M8 .722A7.281 7.281 0 0 1 15.278 8 7.281 7.281 0 0 1 8 15.278 7.281 7.281 0 0 1 .722 8 7.28 7.28 0 0 1 8 .722ZM4.616 12.73A5.78 5.78 0 0 0 8 13.822a5.78 5.78 0 0 0 3.384-1.091A5.78 5.78 0 0 0 8 11.639a5.78 5.78 0 0 0-3.384 1.092Zm7.853-1.005A5.821 5.821 0 0 0 8 2.176a5.821 5.821 0 0 0-4.469 9.55 7.24 7.24 0 0 1 8.938 0Z"/><path d="M8 3.633a2.55 2.55 0 0 1 2.547 2.547A2.55 2.55 0 0 1 8 8.728 2.55 2.55 0 0 1 5.453 6.18 2.55 2.55 0 0 1 8 3.633Zm0 3.64A1.09 1.09 0 0 0 9.092 6.18 1.09 1.09 0 0 0 8 5.09a1.09 1.09 0 0 0-1.092 1.09A1.09 1.09 0 0 0 8 7.272Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 662 B |
33
web/icons/alarm-clock.svg
Normal file
@@ -0,0 +1,33 @@
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs
|
||||
id="defs5" />
|
||||
<path
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;stroke-linecap:butt;stroke-linejoin:miter;enable-background:accumulate;stop-color:#000000;stop-opacity:1;opacity:1"
|
||||
d="m 12,4 c -4.9587181,0 -9,4.0412819 -9,9 0,4.958718 4.0412819,9 9,9 4.958718,0 9,-4.041282 9,-9 0,-4.9587181 -4.041282,-9 -9,-9 z m 0,2 c 3.877838,0 7,3.1221621 7,7 0,3.877838 -3.122162,7 -7,7 C 8.1221621,20 5,16.877838 5,13 5,9.1221621 8.1221621,6 12,6 Z"
|
||||
id="circle1" />
|
||||
<path
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;stroke-linecap:butt;stroke-linejoin:miter;enable-background:accumulate;stop-color:#000000;stop-opacity:1;opacity:1"
|
||||
d="m 12,8 a 1,1 0 0 0 -1,1 v 4 a 1.0001,1.0001 0 0 0 0.292969,0.707031 l 2,2 a 1,1 0 0 0 1.414062,0 1,1 0 0 0 0,-1.414062 L 13,12.585938 V 9 A 1,1 0 0 0 12,8 Z"
|
||||
id="path1" />
|
||||
<path
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;stroke-linecap:butt;stroke-linejoin:miter;enable-background:accumulate;stop-color:#000000;stop-opacity:1;opacity:1"
|
||||
d="M 5,2 A 1,1 0 0 0 4.2929687,2.2929688 l -3,2.9999999 a 1,1 0 0 0 0,1.4140625 1,1 0 0 0 1.4140626,0 L 5.7070312,3.7070313 a 1,1 0 0 0 0,-1.4140625 A 1,1 0 0 0 5,2 Z"
|
||||
id="path2" />
|
||||
<path
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;stroke-linecap:butt;stroke-linejoin:miter;enable-background:accumulate;stop-color:#000000;stop-opacity:1;opacity:1"
|
||||
d="m 18.292969,2.2929688 a 1,1 0 0 0 0,1.4140625 l 3,2.9999999 a 1,1 0 0 0 1.414062,0 1,1 0 0 0 0,-1.4140625 l -3,-2.9999999 a 1,1 0 0 0 -1.414062,0 z"
|
||||
id="path3" />
|
||||
<path
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;stroke-linecap:butt;stroke-linejoin:miter;enable-background:accumulate;stop-color:#000000;stop-opacity:1;opacity:1"
|
||||
d="M 5.6855469,17.980469 3.3046875,20.28125 a 1,1 0 0 0 -0.023437,1.414062 1,1 0 0 0 1.4140625,0.02344 l 2.3789062,-2.298828 a 1,1 0 0 0 0.025391,-1.414063 1,1 0 0 0 -1.4140625,-0.02539 z"
|
||||
id="path4" />
|
||||
<path
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;stroke-linecap:butt;stroke-linejoin:miter;enable-background:accumulate;stop-color:#000000;stop-opacity:1;opacity:1"
|
||||
d="m 18.341797,17.958984 a 1,1 0 0 0 -1.414063,0.0078 1,1 0 0 0 0.0098,1.414062 l 2.359375,2.330078 a 1,1 0 0 0 1.414062,-0.0078 1,1 0 0 0 -0.0078,-1.414063 z"
|
||||
id="path5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
1
web/icons/all-messages.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><path d='M2 .666a.667.667 0 1 0 0 1.333h12a.667.667 0 0 0 0-1.333H2ZM2.333 4c0-.368.299-.667.667-.667h7a.667.667 0 1 1 0 1.334H3A.667.667 0 0 1 2.333 4ZM2 6a.667.667 0 0 0 0 1.333h12A.667.667 0 1 0 14 6H2Zm.333 3.334c0-.369.299-.667.667-.667h7A.667.667 0 0 1 10 10H3a.667.667 0 0 1-.667-.666Zm0 5.334c0-.368.299-.667.667-.667h7a.667.667 0 0 1 0 1.333H3a.667.667 0 0 1-.667-.666ZM2 11.334a.667.667 0 0 0 0 1.333h12a.667.667 0 0 0 0-1.333H2Z'/></svg>
|
||||
|
After Width: | Height: | Size: 532 B |
4
web/icons/archive.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M1.998 2.666v2.001L2 4.669h12.001V2.664H1.998v.001Zm12 3.332v6.669A1.998 1.998 0 0 1 12 14.665H4a1.998 1.998 0 0 1-1.998-1.998V5.998H2A1.332 1.332 0 0 1 .668 4.667v-2c0-.736.597-1.332 1.332-1.332h12c.736 0 1.332.596 1.332 1.332v2c0 .735-.597 1.331-1.332 1.331h-.002Zm-1.33 0H3.332v6.669a.668.668 0 0 0 .668.668h8a.668.668 0 0 0 .668-.668V5.998ZM6.002 8c0-.367.297-.665.665-.665h2.666a.665.665 0 1 1 0 1.33H6.667A.665.665 0 0 1 6.002 8Z"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 542 B |
3
web/icons/arrow-down-left.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.87308 13.8397C2.45899 13.8396 2.12308 13.5039 2.12308 13.0897L2.12308 6.58973C2.12308 6.17561 2.45899 5.83988 2.87308 5.83973C3.2873 5.83973 3.62308 6.17551 3.62308 6.58973L3.62308 11.3534L12.5967 2.37977C12.8896 2.08713 13.3644 2.08702 13.6573 2.37977C13.9501 2.67259 13.9499 3.1474 13.6573 3.44032L4.7598 12.3378L9.37308 12.3378C9.78741 12.338 10.123 12.6744 10.1231 13.0888C10.1231 13.5032 9.78749 13.8395 9.37308 13.8397L2.87308 13.8397Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 551 B |
5
web/icons/arrow-down.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g opacity="0.5">
|
||||
<path d="M12 5L8 11.9282L4 5L12 5Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 151 B |
11
web/icons/arrow-left-circle.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg
|
||||
width="19"
|
||||
height="19"
|
||||
viewBox="0 0 19 19"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="m 9.4999761,1.5474549 c -4.3920605,0 -7.9525213,3.5604611 -7.9525213,7.9525221 0,4.392026 3.5604608,7.952568 7.9525213,7.952568 4.3920259,0 7.9525679,-3.560542 7.9525679,-7.952568 0,-4.392061 -3.560542,-7.9525221 -7.9525679,-7.9525221 z M 0,9.499977 C 0,4.2532904 4.25329,0 9.4999761,0 14.746709,0 19,4.2532904 19,9.499977 19,14.746711 14.746709,19 9.4999761,19 4.25329,19 0,14.746711 0,9.499977 Z M 10.0471,5.4623763 c 0.30216,0.3021492 0.30216,0.7920525 0,1.0942134 L 7.8774175,8.7262495 h 5.1131045 c 0.427238,0 0.773727,0.3464088 0.773727,0.7737275 0,0.427283 -0.346489,0.773775 -0.773727,0.773775 H 7.8774175 L 10.0471,12.443329 c 0.30216,0.302161 0.30216,0.792111 0,1.094271 -0.3021606,0.302162 -0.7920754,0.302162 -1.0942246,0 L 5.4623757,10.047101 C 5.160215,9.74494 5.160215,9.2550253 5.4623757,8.952876 L 8.9528754,5.4623763 c 0.3021492,-0.302161 0.792064,-0.302161 1.0942246,0 z"
|
||||
id="path2"
|
||||
style="stroke-width:1.16349" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
4
web/icons/arrow-left.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M 12,4 A 1,1 0 0 0 11.292969,4.2929687 L 4.2929687,11.292969 a 1.0001,1.0001 0 0 0 0,1.414062 l 7.0000003,7 a 1,1 0 0 0 1.414062,0 1,1 0 0 0 0,-1.414062 L 6.4140625,12 12.707031,5.7070312 a 1,1 0 0 0 0,-1.4140625 A 1,1 0 0 0 12,4 Z" />
|
||||
<path d="m 5,11 a 1,1 0 0 0 -1,1 1,1 0 0 0 1,1 h 14 a 1,1 0 0 0 1,-1 1,1 0 0 0 -1,-1 z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 431 B |
3
web/icons/arrow-up-right.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.1269 2.16028C13.541 2.16043 13.8769 2.49616 13.8769 2.91028V9.41028C13.8769 9.8244 13.541 10.1601 13.1269 10.1603C12.7127 10.1603 12.3769 9.82449 12.3769 9.41028V4.64661L3.40328 13.6202C3.1104 13.9129 2.63556 13.913 2.34274 13.6202C2.04992 13.3274 2.05007 12.8526 2.34274 12.5597L11.2402 3.66223H6.62692C6.21259 3.66198 5.87704 3.32562 5.87692 2.91125C5.87692 2.49679 6.21251 2.16053 6.62692 2.16028H13.1269Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 519 B |
3
web/icons/at-sign.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path clip-rule="evenodd" d="M9.66663 0.858972C8.05483 0.482825 6.36318 0.662985 4.86675 1.37016C3.37032 2.07733 2.15712 3.26992 1.42442 4.75402C0.691714 6.23811 0.482602 7.92643 0.831084 9.54444C1.17956 11.1624 2.06514 12.615 3.34382 13.6659C4.6225 14.7168 6.21908 15.3042 7.87395 15.3327C9.52881 15.3611 11.1446 14.8289 12.4587 13.8226C12.751 13.5988 12.8065 13.1803 12.5827 12.888C12.3588 12.5957 11.9403 12.5402 11.648 12.764C10.5729 13.5874 9.25085 14.0228 7.89687 13.9995C6.54289 13.9762 5.2366 13.4956 4.19041 12.6358C3.14421 11.776 2.41965 10.5875 2.13453 9.26371C1.84941 7.93988 2.0205 6.55853 2.61998 5.34427C3.21947 4.13001 4.21208 3.15425 5.43644 2.57566C6.66079 1.99706 8.04486 1.84966 9.36361 2.15742C10.6824 2.46517 11.8582 3.20999 12.7 4.27076C13.5418 5.33151 13.9999 6.64587 14 8.00004V8.66666C14 9.02029 13.8596 9.35942 13.6095 9.60947C13.3595 9.85952 13.0203 10 12.6667 10C12.3131 10 11.9739 9.85952 11.7239 9.60947C11.4738 9.35942 11.3334 9.02029 11.3334 8.66666V5.33333C11.3334 4.96514 11.0349 4.66666 10.6667 4.66666C10.2986 4.66666 10.0002 4.96501 10 5.3331C9.44292 4.91463 8.75043 4.66666 8.00002 4.66666C6.15907 4.66666 4.66669 6.15905 4.66669 8C4.66669 9.84095 6.15907 11.3333 8.00002 11.3333C8.99209 11.3333 9.88292 10.8999 10.4935 10.2122C10.5792 10.3326 10.6752 10.4464 10.7811 10.5523C11.2812 11.0524 11.9595 11.3333 12.6667 11.3333C13.3739 11.3333 14.0522 11.0524 14.5523 10.5523C15.0524 10.0522 15.3334 9.37391 15.3334 8.66666V7.99995C15.3333 6.34484 14.7733 4.73844 13.7444 3.44194C12.7156 2.14545 11.2784 1.23512 9.66663 0.858972ZM6.00002 8C6.00002 6.89543 6.89545 6 8.00002 6C9.10459 6 10 6.89543 10 8C10 9.10457 9.10459 10 8.00002 10C6.89545 10 6.00002 9.10457 6.00002 8Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
1
web/icons/atlassian.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path d="M41.3175 36.5672L25.4402 4.81262C25.1276 4.12502 24.8151 4 24.44 4C24.1275 4 23.7524 4.12502 23.3774 4.75011C21.127 8.31312 20.1269 12.4387 20.1269 16.7518C20.1269 22.7527 23.1898 28.3785 27.6905 37.4423C28.1906 38.4425 28.5656 38.63 29.4407 38.63H40.4423C41.2549 38.63 41.6925 38.3175 41.6925 37.6924C41.6925 37.3798 41.63 37.1923 41.3175 36.5672ZM18.2516 21.565C17.0014 19.6898 16.6264 19.5647 16.3138 19.5647C16.0013 19.5647 15.8138 19.6898 15.2512 20.8149L7.31255 36.6922C7.06251 37.1923 7 37.3798 7 37.6299C7 38.1299 7.43756 38.63 8.3752 38.63H19.5643C20.3144 38.63 20.877 38.0049 21.1895 36.8172C21.5646 35.317 21.6896 34.0043 21.6896 32.4416C21.6896 28.066 19.7518 23.8154 18.2516 21.565Z"/></svg>
|
||||
|
After Width: | Height: | Size: 797 B |
1
web/icons/attachment.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 22 22'><path d='M13.52 4.59a2 2 0 0 0-1.42.59L5.98 11.3a3.34 3.34 0 1 0 4.72 4.72l6.12-6.12a.67.67 0 0 1 .94.94l-6.12 6.12a4.67 4.67 0 0 1-6.6-6.6l6.12-6.12a3.33 3.33 0 0 1 4.72 4.71l-6.14 6.13a2 2 0 0 1-2.82-2.83l5.66-5.65a.67.67 0 0 1 .94.94l-5.66 5.65a.67.67 0 0 0 .94.95L14.94 8a2 2 0 0 0-1.42-3.42Z' clip-rule='evenodd'></path></svg>
|
||||
|
After Width: | Height: | Size: 414 B |
1
web/icons/bar-chart.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M7.741 1.964 C 7.566 2.026,7.373 2.216,7.308 2.389 C 7.253 2.535,7.253 2.551,7.253 8.000 C 7.253 13.391,7.254 13.467,7.306 13.607 C 7.408 13.879,7.703 14.080,8.000 14.080 C 8.297 14.080,8.592 13.879,8.694 13.607 C 8.746 13.467,8.747 13.391,8.747 8.000 C 8.747 2.551,8.747 2.535,8.692 2.389 C 8.625 2.211,8.432 2.025,8.248 1.962 C 8.096 1.910,7.891 1.911,7.741 1.964 M11.741 5.964 C 11.566 6.026,11.373 6.216,11.308 6.389 C 11.254 6.534,11.253 6.569,11.253 10.001 C 11.253 13.387,11.255 13.469,11.306 13.605 C 11.411 13.882,11.701 14.080,12.000 14.080 C 12.299 14.080,12.589 13.882,12.694 13.605 C 12.745 13.469,12.747 13.387,12.747 10.001 C 12.747 6.569,12.746 6.534,12.692 6.389 C 12.625 6.211,12.432 6.025,12.248 5.962 C 12.096 5.910,11.891 5.911,11.741 5.964 M3.741 8.631 C 3.566 8.693,3.373 8.882,3.308 9.055 C 3.255 9.199,3.253 9.248,3.253 11.333 C 3.253 13.396,3.255 13.469,3.306 13.607 C 3.408 13.879,3.703 14.080,4.000 14.080 C 4.297 14.080,4.592 13.879,4.694 13.607 C 4.745 13.469,4.747 13.396,4.747 11.333 C 4.747 9.248,4.745 9.199,4.692 9.055 C 4.625 8.877,4.432 8.692,4.248 8.629 C 4.096 8.577,3.891 8.578,3.741 8.631 " ></path></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
web/icons/behance.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M4.635 2.733c.435 0 .87.041 1.283.124q.528.124.993.435c.27.207.497.476.642.786.165.393.248.807.227 1.22.02.456-.103.911-.33 1.305-.25.372-.6.662-.994.848a2.36 2.36 0 0 1 1.345.952c.31.496.455 1.076.455 1.655 0 .476-.083.931-.29 1.345a3.1 3.1 0 0 1-.765.952 3.3 3.3 0 0 1-1.118.538 4.5 4.5 0 0 1-1.282.165H0V2.712h4.635zm-.29 4.18c.331.02.683-.083.952-.29.27-.227.414-.58.373-.952 0-.207-.042-.413-.125-.6a.9.9 0 0 0-.33-.351c-.145-.083-.311-.166-.477-.187-.186-.041-.372-.041-.558-.041H2.152v2.42zm.124 4.428c.207 0 .414-.02.6-.062.187-.041.352-.124.518-.228a1.14 1.14 0 0 0 .352-.414c.103-.206.144-.434.124-.662a1.37 1.37 0 0 0-.435-1.138 1.72 1.72 0 0 0-1.138-.33H2.152v2.855h2.317zm6.85-.041c.351.31.807.476 1.262.455.372 0 .724-.104 1.014-.31.248-.166.413-.394.517-.663h1.717a3.48 3.48 0 0 1-1.262 1.925c-.6.413-1.324.6-2.069.579a3.95 3.95 0 0 1-1.531-.29 3.2 3.2 0 0 1-1.138-.807 4.2 4.2 0 0 1-.725-1.241 4.2 4.2 0 0 1-.248-1.593c0-.538.083-1.056.27-1.573a3.54 3.54 0 0 1 3.393-2.4c.558-.021 1.096.124 1.593.372.434.248.828.58 1.117.993.29.435.518.91.621 1.428.124.538.166 1.117.145 1.676h-5.132a1.88 1.88 0 0 0 .456 1.449m2.234-3.953a1.43 1.43 0 0 0-1.096-.413c-.27 0-.538.041-.766.165a1.9 1.9 0 0 0-.497.414 1.6 1.6 0 0 0-.269.538c-.041.165-.082.331-.082.497h3.186a2.34 2.34 0 0 0-.476-1.2M10.429 3.416h3.973V4.43h-3.973Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
web/icons/bitbucket.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="m15.078 7.222-1.193 7.285c-.077.441-.388.7-.83.7H2.945c-.441 0-.752-.259-.83-.7L.015 1.52C-.063 1.078.17.793.585.793h14.83c.415 0 .648.285.57.726l-.57 3.422c-.078.492-.363.7-.83.7H5.667c-.13 0-.207.077-.182.233l.7 4.304c.027.103.105.181.208.181h3.215c.104 0 .182-.077.207-.182l.493-3.11c.052-.39.311-.545.674-.545h3.5c.518 0 .674.26.596.7"/></svg>
|
||||
|
After Width: | Height: | Size: 440 B |
1
web/icons/bluesky.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><path d="M3.468 1.904C5.303 3.282 7.276 6.074 8 7.572c.725-1.498 2.697-4.29 4.532-5.668C13.855.911 16 .142 16 2.588c0 .489-.28 4.105-.444 4.692-.572 2.04-2.653 2.561-4.504 2.246 3.236.551 4.06 2.375 2.281 4.2-3.376 3.464-4.852-.87-5.23-1.98-.07-.204-.103-.299-.103-.218 0-.081-.033.014-.102.218-.379 1.11-1.855 5.444-5.231 1.98-1.778-1.825-.955-3.649 2.28-4.2-1.85.315-3.932-.205-4.503-2.246C.28 6.693 0 3.077 0 2.588 0 .142 2.145.911 3.468 1.904"/></svg>
|
||||
|
After Width: | Height: | Size: 519 B |
1
web/icons/bold.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 22 22'><path d='M7.94 17h3.93c2.58 0 4.13-1.28 4.13-3.4 0-1.59-1.1-2.75-2.76-2.93v-.06a2.67 2.67 0 0 0 2.16-2.62C15.4 6.2 14 5 11.84 5h-3.9c-.59 0-.94.37-.94.96v10.08c0 .6.35.96.94.96Zm.94-6.9V6.53h2.5c1.37 0 2.14.62 2.14 1.73 0 1.2-.9 1.86-2.53 1.86H8.9Zm0 5.38v-3.95h2.49c1.78 0 2.7.66 2.7 1.96 0 1.3-.9 1.99-2.6 1.99H8.89Z'></path></svg>
|
||||
|
After Width: | Height: | Size: 416 B |
3
web/icons/bot.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.5625 1.3125C7.56255 1.40754 7.55229 1.5023 7.53187 1.59512C7.96675 2.01512 8.26075 2.464 8.4375 2.996C8.57837 3.423 8.63525 3.87975 8.651 4.375H12.8125C13.3927 4.375 13.9491 4.60547 14.3593 5.0157C14.7695 5.42594 15 5.98234 15 6.5625V11.8125C15 12.3927 14.7695 12.9491 14.3593 13.3593C13.9491 13.7695 13.3927 14 12.8125 14H3.1875C2.60734 14 2.05094 13.7695 1.6407 13.3593C1.23047 12.9491 1 12.3927 1 11.8125V6.5625C1 5.98234 1.23047 5.42594 1.6407 5.0157C2.05094 4.60547 2.60734 4.375 3.1875 4.375H7.33762C7.32275 3.97862 7.279 3.67325 7.1915 3.409C7.09612 3.12112 6.93775 2.85425 6.6455 2.56462C6.4488 2.62679 6.24022 2.64171 6.03668 2.60818C5.83314 2.57465 5.64037 2.49362 5.474 2.37166C5.30763 2.2497 5.17235 2.09023 5.07914 1.90621C4.98593 1.72219 4.9374 1.51878 4.9375 1.3125C4.9375 0.964403 5.07578 0.630564 5.32192 0.384422C5.56806 0.138281 5.9019 0 6.25 0C6.5981 0 6.93194 0.138281 7.17808 0.384422C7.42422 0.630564 7.5625 0.964403 7.5625 1.3125ZM6.25 8.3125C6.25 7.9644 6.11172 7.63056 5.86558 7.38442C5.61944 7.13828 5.2856 7 4.9375 7C4.5894 7 4.25556 7.13828 4.00942 7.38442C3.76328 7.63056 3.625 7.9644 3.625 8.3125C3.625 8.6606 3.76328 8.99444 4.00942 9.24058C4.25556 9.48672 4.5894 9.625 4.9375 9.625C5.2856 9.625 5.61944 9.48672 5.86558 9.24058C6.11172 8.99444 6.25 8.6606 6.25 8.3125ZM11.0625 9.625C11.4106 9.625 11.7444 9.48672 11.9906 9.24058C12.2367 8.99444 12.375 8.6606 12.375 8.3125C12.375 7.9644 12.2367 7.63056 11.9906 7.38442C11.7444 7.13828 11.4106 7 11.0625 7C10.7144 7 10.3806 7.13828 10.1344 7.38442C9.88828 7.63056 9.75 7.9644 9.75 8.3125C9.75 8.6606 9.88828 8.99444 10.1344 9.24058C10.3806 9.48672 10.7144 9.625 11.0625 9.625ZM8 12.25C8.96687 12.25 9.75 11.662 9.75 10.9375H6.25C6.25 11.662 7.03312 12.25 8 12.25Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
3
web/icons/browse-channels.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="m 8.864,3.335 h 5.667 a 0.665,0.665 0 0 1 0,1.33 H 8.864 a 0.665,0.665 0 1 1 0,-1.33 z M 15.196,8 a 0.665,0.665 0 0 1 -0.665,0.665 h -4.333 a 0.665,0.665 0 1 1 0,-1.33 h 4.333 c 0.368,0 0.665,0.298 0.665,0.665 z m -6.997,4 c 0,-0.367 0.298,-0.665 0.665,-0.665 h 5.667 a 0.665,0.665 0 0 1 0,1.33 H 8.864 A 0.665,0.665 0 0 1 8.199,12 Z M 4.22,4.694 4.064,6.103 H 5.387 L 5.559,4.556 A 0.62531852,0.62531852 0 0 1 6.802,4.694 L 6.644,6.103 h 0.93 a 0.625,0.625 0 1 1 0,1.25 H 6.505 L 6.354,8.718 h 1.075 a 0.625,0.625 0 0 1 0,1.25 H 6.216 L 6.052,11.444 A 0.62531852,0.62531852 0 1 1 4.809,11.306 L 4.958,9.968 H 3.634 L 3.47,11.444 A 0.625,0.625 0 1 1 2.228,11.306 L 2.377,9.968 H 1.429 a 0.625,0.625 0 1 1 0,-1.25 H 2.515 L 2.667,7.353 H 1.574 a 0.625,0.625 0 1 1 0,-1.25 H 2.806 L 2.978,4.556 A 0.625,0.625 0 0 1 4.22,4.694 Z M 5.248,7.353 H 3.925 L 3.773,8.718 h 1.324 z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 977 B |
1
web/icons/building.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M1.333 8.000 L 1.333 14.000 8.000 14.000 L 14.667 14.000 14.667 9.333 L 14.667 4.667 11.333 4.667 L 8.000 4.667 8.000 3.333 L 8.000 2.000 4.667 2.000 L 1.333 2.000 1.333 8.000 M4.000 4.000 L 4.000 4.667 3.333 4.667 L 2.667 4.667 2.667 4.000 L 2.667 3.333 3.333 3.333 L 4.000 3.333 4.000 4.000 M6.667 4.000 L 6.667 4.667 6.000 4.667 L 5.333 4.667 5.333 4.000 L 5.333 3.333 6.000 3.333 L 6.667 3.333 6.667 4.000 M4.000 6.667 L 4.000 7.333 3.333 7.333 L 2.667 7.333 2.667 6.667 L 2.667 6.000 3.333 6.000 L 4.000 6.000 4.000 6.667 M6.667 6.667 L 6.667 7.333 6.000 7.333 L 5.333 7.333 5.333 6.667 L 5.333 6.000 6.000 6.000 L 6.667 6.000 6.667 6.667 M13.333 9.333 L 13.333 12.667 10.667 12.667 L 8.000 12.667 8.000 12.000 L 8.000 11.333 8.667 11.333 L 9.333 11.333 9.333 10.667 L 9.333 10.000 8.667 10.000 L 8.000 10.000 8.000 9.333 L 8.000 8.667 8.667 8.667 L 9.333 8.667 9.333 8.000 L 9.333 7.333 8.667 7.333 L 8.000 7.333 8.000 6.667 L 8.000 6.000 10.667 6.000 L 13.333 6.000 13.333 9.333 M10.667 8.000 L 10.667 8.667 11.333 8.667 L 12.000 8.667 12.000 8.000 L 12.000 7.333 11.333 7.333 L 10.667 7.333 10.667 8.000 M4.000 9.333 L 4.000 10.000 3.333 10.000 L 2.667 10.000 2.667 9.333 L 2.667 8.667 3.333 8.667 L 4.000 8.667 4.000 9.333 M6.667 9.333 L 6.667 10.000 6.000 10.000 L 5.333 10.000 5.333 9.333 L 5.333 8.667 6.000 8.667 L 6.667 8.667 6.667 9.333 M10.667 10.667 L 10.667 11.333 11.333 11.333 L 12.000 11.333 12.000 10.667 L 12.000 10.000 11.333 10.000 L 10.667 10.000 10.667 10.667 M4.000 12.000 L 4.000 12.667 3.333 12.667 L 2.667 12.667 2.667 12.000 L 2.667 11.333 3.333 11.333 L 4.000 11.333 4.000 12.000 M6.667 12.000 L 6.667 12.667 6.000 12.667 L 5.333 12.667 5.333 12.000 L 5.333 11.333 6.000 11.333 L 6.667 11.333 6.667 12.000 " ></path></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
3
web/icons/calendar-clock.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M5.333.668c.368 0 .665.298.665.665v.669h4.004v-.669a.665.665 0 1 1 1.33 0v.669h1.335A1.998 1.998 0 0 1 14.665 4v1a.665.665 0 1 1-1.33 0V4a.668.668 0 0 0-.668-.668h-1.335V4a.665.665 0 0 1-1.33 0v-.668H5.998V4a.665.665 0 1 1-1.33 0v-.668H3.333A.668.668 0 0 0 2.665 4v2.002h2.668a.665.665 0 1 1 0 1.33H2.665v6.001a.668.668 0 0 0 .668.669h2.334a.665.665 0 1 1 0 1.33H3.333a1.999 1.999 0 0 1-1.998-1.999V4a1.998 1.998 0 0 1 1.998-1.998h1.335v-.669c0-.367.298-.665.665-.665Zm5.334 6.664a3.335 3.335 0 1 0 0 6.67 3.335 3.335 0 0 0 0-6.67Zm-4.665 3.335a4.665 4.665 0 1 1 9.33 0 4.665 4.665 0 0 1-9.33 0Zm4.665-1.999c.367 0 .665.298.665.665v1.214l.75.6a.665.665 0 1 1-.83 1.039l-1-.8a.665.665 0 0 1-.25-.52V9.334c0-.367.297-.665.665-.665Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 834 B |
6
web/icons/calendar-days.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g>
|
||||
<path d="M4.668 8.665c0-.367.298-.665.665-.665h.007a.665.665 0 1 1 0 1.33h-.007a.665.665 0 0 1-.665-.665ZM8 8h.007a.665.665 0 1 1 0 1.33H8A.665.665 0 1 1 8 8Zm2.002.665c0-.367.297-.665.665-.665h.006a.665.665 0 0 1 0 1.33h-.006a.665.665 0 0 1-.665-.665Zm-4.669 2.002h.007a.665.665 0 1 1 0 1.33h-.007a.665.665 0 1 1 0-1.33Zm2.002.665c0-.367.298-.665.665-.665h.007a.665.665 0 1 1 0 1.33H8a.665.665 0 0 1-.665-.665Zm3.332-.665h.006a.665.665 0 0 1 0 1.33h-.006a.665.665 0 1 1 0-1.33Z"/>
|
||||
<path d="M11.332.665v.669h1.335c1.103 0 1.998.895 1.998 1.998v9.334a1.998 1.998 0 0 1-1.998 1.998H3.333a1.998 1.998 0 0 1-1.998-1.998V3.332c0-1.103.895-1.998 1.998-1.998h1.335V.665a.665.665 0 0 1 1.33 0v.669h4.004V.665a.665.665 0 1 1 1.33 0Zm2.003 4.669V3.332c0-.369-.3-.668-.668-.668h-1.335v.668a.665.665 0 1 1-1.33 0v-.668H5.998v.668a.665.665 0 1 1-1.33 0v-.668H3.333c-.369 0-.668.3-.668.668v2.002h10.67Zm-10.67 1.33v6.002c0 .369.3.668.668.668h9.334c.369 0 .668-.3.668-.668V6.664H2.665Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
3
web/icons/calendar.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M11.332 1.33297V2.00195H12.6676C13.7713 2.00195 14.6659 2.89664 14.6659 4.00029V13.3336C14.6659 14.4373 13.7713 15.332 12.6676 15.332H3.33427C2.23062 15.332 1.33594 14.4373 1.33594 13.3336V4.00029C1.33594 2.89664 2.23062 2.00195 3.33427 2.00195H4.66797V1.33297C4.66797 0.965699 4.9657 0.667969 5.33297 0.667969C5.70024 0.667969 5.99797 0.965699 5.99797 1.33297V2.00195H10.002V1.33297C10.002 0.965699 10.2997 0.667969 10.667 0.667969C11.0342 0.667969 11.332 0.965699 11.332 1.33297ZM13.3359 6.00195V4.00029C13.3359 3.63118 13.0367 3.33195 12.6676 3.33195H11.332V3.99964C11.332 4.3669 11.0342 4.66464 10.667 4.66464C10.2997 4.66464 10.002 4.3669 10.002 3.99964V3.33195H5.99797V3.99964C5.99797 4.3669 5.70024 4.66464 5.33297 4.66464C4.9657 4.66464 4.66797 4.3669 4.66797 3.99964V3.33195H3.33427C2.96516 3.33195 2.66594 3.63118 2.66594 4.00029V6.00195H13.3359ZM2.66594 7.33195V13.3336C2.66594 13.7027 2.96516 14.002 3.33427 14.002H12.6676C13.0367 14.002 13.3359 13.7027 13.3359 13.3336V7.33195H2.66594Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
4
web/icons/check-x.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M12.8 2.47 5.47 9.8a.67.67 0 0 1-.94 0L1.2 6.47a.66.66 0 1 1 .94-.94L5 8.39l6.86-6.86a.66.66 0 1 1 .94.94Zm-4.33 6c.3-.3.77-.3 1.06 0l1.97 1.97 1.97-1.97a.75.75 0 1 1 1.06 1.06l-1.97 1.97 1.97 1.97a.75.75 0 1 1-1.06 1.06l-1.97-1.97-1.97 1.97a.75.75 0 0 1-1.06-1.06l1.97-1.97-1.97-1.97a.75.75 0 0 1 0-1.06Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 421 B |
3
web/icons/check.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.5195 2.5395C14.7748 2.27431 15.1966 2.26589 15.4619 2.52094C15.7271 2.77618 15.7355 3.19803 15.4805 3.46332L5.85547 13.4633C5.72979 13.5939 5.55623 13.6684 5.375 13.6684C5.19377 13.6684 5.02021 13.5939 4.89453 13.4633L0.51953 8.9184L0.436522 8.81196C0.270617 8.54997 0.305958 8.19944 0.538084 7.97602C0.770174 7.75265 1.12151 7.73099 1.37695 7.90668L1.48047 7.9936L5.375 12.0395L14.5195 2.5395Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 503 B |
3
web/icons/chevron-down.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path clip-rule="evenodd" d="M3.52977 5.52973C3.78947 5.27004 4.21053 5.27004 4.47023 5.52973L8 9.05951L11.5298 5.52973C11.7895 5.27004 12.2105 5.27004 12.4702 5.52973C12.7299 5.78943 12.7299 6.21049 12.4702 6.47019L8.47023 10.4702C8.21053 10.7299 7.78947 10.7299 7.52977 10.4702L3.52977 6.47019C3.27008 6.21049 3.27008 5.78943 3.52977 5.52973Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 439 B |
3
web/icons/chevron-left.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.4702 3.52973C10.2105 3.27004 9.78947 3.27004 9.52977 3.52973L5.52977 7.52974C5.27008 7.78943 5.27008 8.21049 5.52977 8.47019L9.52977 12.4702C9.78947 12.7299 10.2105 12.7299 10.4702 12.4702C10.7299 12.2105 10.7299 11.7894 10.4702 11.5297L6.94045 7.99996L10.4702 4.47019C10.7299 4.21049 10.7299 3.78943 10.4702 3.52973Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 428 B |
3
web/icons/chevron-right.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path clip-rule="evenodd" d="M5.52977 3.52973C5.78947 3.27004 6.21053 3.27004 6.47023 3.52973L10.4702 7.52974C10.7299 7.78943 10.7299 8.21049 10.4702 8.47019L6.47023 12.4702C6.21053 12.7299 5.78947 12.7299 5.52977 12.4702C5.27008 12.2105 5.27008 11.7894 5.52977 11.5297L9.05955 7.99996L5.52977 4.47019C5.27008 4.21049 5.27008 3.78943 5.52977 3.52973Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |
8
web/icons/circle-x.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<g>
|
||||
<path
|
||||
d="M10.47 5.53c.26.26.26.68 0 .94L8.94 8l1.53 1.53a.665.665 0 0 1-.94.94L8 8.94l-1.53 1.53a.665.665 0 0 1-.94-.94L7.06 8 5.53 6.47a.665.665 0 0 1 .94-.94L8 7.06l1.53-1.53c.26-.26.68-.26.94 0Z" />
|
||||
<path
|
||||
d="M.668 8a7.332 7.332 0 1 1 14.664 0A7.332 7.332 0 0 1 .668 8ZM8 1.998a6.002 6.002 0 1 0 0 12.004A6.002 6.002 0 0 0 8 1.998Z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 485 B |
1
web/icons/clock.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M7.380 0.693 C 5.906 0.836,4.631 1.339,3.467 2.235 C 3.128 2.497,2.420 3.219,2.153 3.575 C 1.381 4.609,0.919 5.728,0.729 7.027 C 0.663 7.475,0.663 8.525,0.729 8.973 C 0.920 10.273,1.382 11.394,2.154 12.424 C 2.457 12.829,3.171 13.543,3.576 13.846 C 4.606 14.618,5.727 15.080,7.027 15.271 C 7.475 15.337,8.525 15.337,8.973 15.271 C 10.273 15.080,11.394 14.618,12.424 13.846 C 12.829 13.543,13.543 12.829,13.846 12.424 C 14.617 11.395,15.079 10.275,15.271 8.973 C 15.337 8.526,15.337 7.476,15.271 7.027 C 15.080 5.727,14.617 4.604,13.846 3.576 C 13.596 3.242,12.907 2.530,12.599 2.288 C 11.535 1.450,10.365 0.942,9.053 0.747 C 8.631 0.684,7.759 0.656,7.380 0.693 M8.880 2.067 C 9.513 2.163,10.044 2.329,10.629 2.615 C 12.418 3.489,13.632 5.151,13.937 7.147 C 14.002 7.569,14.002 8.431,13.937 8.853 C 13.632 10.850,12.420 12.510,10.627 13.389 C 8.545 14.409,6.064 14.132,4.246 12.675 C 3.235 11.865,2.481 10.687,2.178 9.440 C 2.054 8.931,2.016 8.592,2.016 8.000 C 2.016 7.004,2.187 6.253,2.613 5.373 C 3.196 4.171,4.165 3.202,5.373 2.612 C 6.060 2.276,6.684 2.107,7.560 2.016 C 7.775 1.994,8.608 2.026,8.880 2.067 M7.776 3.375 C 7.624 3.431,7.505 3.531,7.421 3.674 L 7.347 3.800 7.339 5.945 C 7.333 7.882,7.336 8.102,7.376 8.222 C 7.461 8.476,7.512 8.508,9.056 9.277 C 10.608 10.049,10.610 10.050,10.881 9.960 C 11.375 9.795,11.488 9.104,11.068 8.806 C 11.013 8.766,10.449 8.475,9.817 8.160 L 8.667 7.587 8.667 5.745 C 8.667 3.667,8.673 3.738,8.467 3.533 C 8.286 3.351,8.012 3.289,7.776 3.375 " ></path></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
1
web/icons/close-small.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><path d='M12.971 3.971a.665.665 0 1 0-.94-.94L8 7.06 3.97 3.03a.665.665 0 1 0-.94.94L7.06 8l-4.03 4.03a.665.665 0 0 0 .94.94L8 8.94l4.03 4.03a.665.665 0 0 0 .94-.94L8.941 8l4.03-4.03Z'/></svg>
|
||||
|
After Width: | Height: | Size: 275 B |
3
web/icons/close.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.4702 2.47019C14.7299 2.21049 14.7299 1.78943 14.4702 1.52973C14.2105 1.27004 13.7895 1.27004 13.5298 1.52973L8 7.05951L2.47023 1.52973C2.21053 1.27004 1.78947 1.27004 1.52977 1.52973C1.27008 1.78943 1.27008 2.21049 1.52977 2.47019L7.05955 7.99996L1.52977 13.5297C1.27008 13.7894 1.27008 14.2105 1.52977 14.4702C1.78947 14.7299 2.21053 14.7299 2.47023 14.4702L8 8.94041L13.5298 14.4702C13.7895 14.7299 14.2105 14.7299 14.4702 14.4702C14.7299 14.2105 14.7299 13.7894 14.4702 13.5297L8.94045 7.99996L14.4702 2.47019Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 621 B |
1
web/icons/code.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22"><g><path d="M12.003 7.89a.665.665 0 0 1 .939-.055l3 2.667a.665.665 0 0 1 0 .994l-3 2.666a.665.665 0 1 1-.884-.994l2.441-2.17-2.44-2.169a.665.665 0 0 1-.056-.939Zm-2.061.94L7.5 10.998l2.44 2.17a.665.665 0 0 1-.883.993l-3-2.666a.665.665 0 0 1 0-.994l3-2.667a.665.665 0 0 1 .884.994Z"/><path d="M4 3h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Zm14 1.33H4c-.37 0-.67.3-.67.67v12c0 .37.3.67.67.67h14c.37 0 .67-.3.67-.67V5c0-.37-.3-.67-.67-.67Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 556 B |
1
web/icons/codeberg.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M42519.285-7078.79a.76.568 0 0 0-.738.675l33.586 125.888a87.2 87.2 0 0 0 39.381-33.763l-71.565-92.52a.76.568 0 0 0-.664-.28" transform="translate(-3893.49 653.646)scale(.09177)"/><path d="M11249.461-1883.696c-12.74 0-23.067 10.327-23.067 23.067 0 4.334 1.22 8.58 3.522 12.251l19.232-24.863c.138-.18.486-.18.624 0l19.233 24.864a23.07 23.07 0 0 0 3.523-12.252c0-12.74-10.327-23.067-23.067-23.067" transform="translate(-3893.49 653.646)scale(.34682)"/></svg>
|
||||
|
After Width: | Height: | Size: 548 B |
1
web/icons/collapse-diagonal.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M14.47 1.53c.26.26.26.68 0 .94L10.94 6h2.4a.67.67 0 0 1 0 1.33H9.35a.66.66 0 0 1-.7-.66v-4a.67.67 0 0 1 1.34 0v2.4l3.53-3.54a.67.67 0 0 1 .94 0h.01ZM2.67 8.67v-.02h4a.66.66 0 0 1 .66.7v3.99a.67.67 0 0 1-1.33 0v-2.4l-3.53 3.53a.67.67 0 0 1-.94-.94L5.07 10h-2.4a.665.665 0 0 1 0-1.33Z"/></svg>
|
||||
|
After Width: | Height: | Size: 383 B |
4
web/icons/collapse.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M7.529 9.529c.26-.26.682-.26.942 0l3.334 3.333a.667.667 0 1 1-.943.943L8 10.943l-2.862 2.862a.667.667 0 1 1-.943-.943L7.53 9.529ZM4.195 2.195c.26-.26.683-.26.943 0L8 5.057l2.862-2.862a.667.667 0 1 1 .943.943L8.47 6.471a.667.667 0 0 1-.942 0L4.195 3.138a.667.667 0 0 1 0-.943Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 391 B |
4
web/icons/compose-edit.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.001 2.74414C17.5993 2.74414 18.1731 2.98182 18.5962 3.4049C19.0193 3.82799 19.2569 4.4018 19.2569 5.00013C19.2569 5.59846 19.0193 6.17228 18.5962 6.59536L11.4712 13.7204C11.386 13.8056 11.2792 13.866 11.1622 13.8953L8.16224 14.6453C7.93562 14.7019 7.6959 14.6355 7.53073 14.4704C7.36555 14.3052 7.29915 14.0655 7.35581 13.8388L8.10581 10.8388C8.13504 10.7219 8.1955 10.6151 8.28073 10.5299L15.4057 3.4049C15.8288 2.98182 16.4026 2.74414 17.001 2.74414ZM16.3462 4.34536L9.35142 11.3401L8.91491 13.0862L10.661 12.6497L17.6557 5.65491C17.8294 5.48125 17.9269 5.24572 17.9269 5.00013C17.9269 4.75454 17.8294 4.51901 17.6557 4.34536C17.4821 4.1717 17.2465 4.07414 17.001 4.07414C16.7554 4.07414 16.5198 4.1717 16.3462 4.34536Z"/>
|
||||
<path d="M5.00094 4.33594C4.42674 4.33594 3.87607 4.56403 3.47005 4.97005C3.06403 5.37607 2.83594 5.92674 2.83594 6.50094V17.0009C2.83594 17.5751 3.06403 18.1258 3.47005 18.5318C3.87607 18.9378 4.42674 19.1659 5.00094 19.1659H15.5009C16.0751 19.1659 16.6258 18.9378 17.0318 18.5318C17.4378 18.1258 17.6659 17.5751 17.6659 17.0009V11.7509C17.6659 11.3837 17.3682 11.0859 17.0009 11.0859C16.6337 11.0859 16.3359 11.3837 16.3359 11.7509V17.0009C16.3359 17.2224 16.248 17.4348 16.0914 17.5914C15.9348 17.748 15.7224 17.8359 15.5009 17.8359H5.00094C4.77948 17.8359 4.5671 17.748 4.4105 17.5914C4.25391 17.4348 4.16594 17.2224 4.16594 17.0009V6.50094C4.16594 6.27948 4.25391 6.0671 4.4105 5.9105C4.5671 5.75391 4.77948 5.66594 5.00094 5.66594H10.2509C10.6182 5.66594 10.9159 5.36821 10.9159 5.00094C10.9159 4.63367 10.6182 4.33594 10.2509 4.33594H5.00094Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
5
web/icons/compose-scroll-left.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(3.5,4.46)">
|
||||
<path d="M 0,3.54 9,0.08 V 7 Z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 152 B |
5
web/icons/compose-scroll-right.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(-1,0,0,1,12.5,4.46)">
|
||||
<path d="M 0,3.54 9,0.08 V 7 Z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 159 B |
4
web/icons/copy.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.19396 2.19372C2.3193 2.06838 2.48929 1.99797 2.66655 1.99797H8.66655C8.8438 1.99797 9.01379 2.06838 9.13913 2.19372C9.26447 2.31906 9.33488 2.48905 9.33488 2.6663V3.33297C9.33488 3.70024 9.63261 3.99797 9.99988 3.99797C10.3671 3.99797 10.6649 3.70024 10.6649 3.33297V2.6663C10.6649 2.13631 10.4543 1.62803 10.0796 1.25327C9.70482 0.878507 9.19654 0.667969 8.66655 0.667969H2.66655C2.13656 0.667969 1.62827 0.878507 1.25351 1.25327C0.878751 1.62803 0.668213 2.13631 0.668213 2.6663V8.6663C0.668213 9.19629 0.878751 9.70458 1.25351 10.0793C1.62827 10.4541 2.13656 10.6646 2.66655 10.6646H3.33321C3.70048 10.6646 3.99821 10.3669 3.99821 9.99964C3.99821 9.63237 3.70048 9.33464 3.33321 9.33464H2.66655C2.48929 9.33464 2.3193 9.26422 2.19396 9.13889C2.06863 9.01355 1.99821 8.84356 1.99821 8.6663V2.6663C1.99821 2.48905 2.06863 2.31906 2.19396 2.19372Z"/>
|
||||
<path clip-rule="evenodd" d="M7.33329 5.33594C6.22965 5.33594 5.33496 6.23062 5.33496 7.33427V13.3343C5.33496 14.4379 6.22965 15.3326 7.33329 15.3326H13.3333C14.4369 15.3326 15.3316 14.4379 15.3316 13.3343V7.33427C15.3316 6.23062 14.4369 5.33594 13.3333 5.33594H7.33329ZM6.66496 7.33427C6.66496 6.96516 6.96418 6.66594 7.33329 6.66594H13.3333C13.7024 6.66594 14.0016 6.96516 14.0016 7.33427V13.3343C14.0016 13.7034 13.7024 14.0026 13.3333 14.0026H7.33329C6.96418 14.0026 6.66496 13.7034 6.66496 13.3343V7.33427Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
3
web/icons/corner-down-right.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M3.333 2.667v4.666a2 2 0 0 0 2 2h6.39L9.53 7.138a.667.667 0 0 1 .942-.943l3.333 3.333.005.005a.664.664 0 0 1 0 .934l-.005.005-3.333 3.333a.667.667 0 1 1-.942-.943l2.195-2.195h-6.39A3.333 3.333 0 0 1 2 7.333V2.667a.667.667 0 0 1 1.333 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 342 B |
1
web/icons/credit-card.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M2.318 2.734 C 1.806 2.859,1.370 3.211,1.131 3.693 C 0.923 4.114,0.934 3.862,0.934 8.000 C 0.934 12.148,0.922 11.884,1.137 12.319 C 1.399 12.850,1.915 13.218,2.523 13.306 C 2.774 13.342,13.226 13.342,13.477 13.306 C 13.896 13.245,14.224 13.080,14.535 12.772 C 14.699 12.610,14.763 12.522,14.863 12.319 C 15.078 11.884,15.066 12.148,15.066 8.000 C 15.066 3.852,15.078 4.116,14.863 3.681 C 14.657 3.264,14.296 2.946,13.836 2.778 L 13.640 2.707 8.053 2.702 C 3.419 2.698,2.441 2.704,2.318 2.734 M13.453 4.058 C 13.559 4.116,13.670 4.235,13.705 4.328 C 13.723 4.376,13.733 4.730,13.733 5.295 L 13.733 6.187 8.000 6.187 L 2.267 6.187 2.267 5.292 C 2.267 4.602,2.275 4.379,2.303 4.312 C 2.347 4.206,2.509 4.060,2.607 4.038 C 2.647 4.029,2.692 4.018,2.707 4.013 C 2.721 4.008,5.127 4.006,8.053 4.009 C 12.956 4.013,13.380 4.017,13.453 4.058 M13.733 9.545 C 13.733 10.930,13.724 11.621,13.705 11.672 C 13.665 11.778,13.551 11.892,13.427 11.949 C 13.324 11.997,13.122 11.999,8.000 11.999 C 2.878 11.999,2.676 11.997,2.573 11.949 C 2.449 11.892,2.335 11.778,2.295 11.672 C 2.276 11.621,2.267 10.930,2.267 9.545 L 2.267 7.493 8.000 7.493 L 13.733 7.493 13.733 9.545 " ></path></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
4
web/icons/deactivated-circle.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="m 2.3431459,2.3431458 a 7.9999996,7.9999996 0 0 0 0,11.3137082 7.9999996,7.9999996 0 0 0 11.3137081,0 7.9999996,7.9999996 0 0 0 0,-11.3137082 7.9999996,7.9999996 0 0 0 -11.3137081,0 z M 3.6287946,3.6287945 A 6.1818179,6.1818179 0 0 1 11.529507,2.9277144 L 2.9337411,11.52348 A 6.1818179,6.1818179 0 0 1 3.6287946,3.6287945 Z M 4.4765193,13.066259 13.066259,4.4765192 a 6.1818179,6.1818179 0 0 1 -0.695054,7.8946858 6.1818179,6.1818179 0 0 1 -7.8946857,0.695054 z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 550 B |
1
web/icons/discord.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M10.252 1.936a9 9 0 0 0-.424.858 12.4 12.4 0 0 0-3.663 0 9 9 0 0 0-.425-.858 13.3 13.3 0 0 0-3.302 1.018C.351 6.046-.214 9.058.067 12.029a13.3 13.3 0 0 0 4.05 2.032 10 10 0 0 0 .867-1.398 9 9 0 0 1-1.366-.65q.173-.127.336-.253a9.5 9.5 0 0 0 8.095 0q.162.136.335.252a8.7 8.7 0 0 1-1.37.655 10 10 0 0 0 .869 1.397 13.3 13.3 0 0 0 4.05-2.03c.33-3.445-.569-6.432-2.378-9.077a13.1 13.1 0 0 0-3.3-1.015ZM5.342 10.2c-.788 0-1.443-.715-1.443-1.599C3.9 7.718 4.528 7 5.34 7S6.793 7.72 6.78 8.6 6.146 10.2 5.34 10.2m5.317 0c-.792 0-1.44-.715-1.44-1.599 0-.883.63-1.601 1.44-1.601s1.452.72 1.439 1.601-.635 1.599-1.44 1.599"/></svg>
|
||||
|
After Width: | Height: | Size: 714 B |
1
web/icons/dm-groups-3.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><path d='m 5.9731762,5.6910339 a 2.54,2.54 0 0 0 -3.47,0.93 2.53,2.53 0 1 0 3.47,-0.93 z m 6.4599998,-3.59 a 2.54,2.54 0 0 0 -3.4599998,0.93 2.53,2.53 0 1 0 3.4599998,-0.93 z m 0.12,7.39 A 2.54,2.54 0 0 0 9.0931762,10.421034 2.54,2.54 0 1 0 12.553176,9.4910339 Z'/></svg>
|
||||
|
After Width: | Height: | Size: 355 B |
3
web/icons/download.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M8 1.335c.367 0 .665.298.665.665v6.395l2.198-2.199a.665.665 0 0 1 .94.94L8.47 10.47c-.26.26-.68.26-.94 0L4.196 7.137a.665.665 0 1 1 .94-.94l2.199 2.198V2c0-.367.298-.665.665-.665Zm-6 8c.367 0 .665.298.665.665v2.667a.668.668 0 0 0 .668.668h9.334a.668.668 0 0 0 .668-.668V10a.665.665 0 1 1 1.33 0v2.667a1.998 1.998 0 0 1-1.998 1.998H3.333a1.998 1.998 0 0 1-1.998-1.998V10c0-.367.298-.665.665-.665Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 501 B |
1
web/icons/drafts.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><path d='M6.03 12.38h3.93a.65.65 0 0 0 .66-.66.64.64 0 0 0-.18-.47.63.63 0 0 0-.47-.19H6.04a.65.65 0 0 0-.67.65c0 .19.07.35.2.47.12.13.27.2.46.2Zm0-2.63h3.93a.65.65 0 0 0 .66-.65.64.64 0 0 0-.18-.47.63.63 0 0 0-.47-.2H6.04a.65.65 0 0 0-.67.66c0 .18.07.34.2.47.12.13.27.19.46.19ZM4.06 15c-.36 0-.67-.13-.93-.39a1.27 1.27 0 0 1-.38-.92V2.3c0-.36.13-.67.39-.92.25-.26.56-.39.92-.39H9.2c.18 0 .34.03.5.1.15.05.3.15.43.28l2.74 2.74a1.26 1.26 0 0 1 .38.93v8.64c0 .36-.13.67-.39.92-.25.26-.56.39-.92.39H4.06ZM8.88 4.72v-2.4H4.05v11.37h7.88V5.38h-2.4a.63.63 0 0 1-.48-.2.63.63 0 0 1-.19-.46Z'/></svg>
|
||||
|
After Width: | Height: | Size: 676 B |
1
web/icons/dribbble.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M24 31.999A8 8 0 1 1 32 40h-.649v-.029C27.238 39.641 24 36.196 24 32m8.199 6.7a6.702 6.702 0 1 0-6.901-6.7 6.704 6.704 0 0 0 6.702 6.7z" transform="translate(-24 -24)"/><path d="m28.761 26.138-.277-.09.417-1.229.188.06c.145.047.326.105.435.144l.006.002.005.003.095.035c.168.064.375.142.548.223l.008.004.034.017.306.145.14.067.016.007.015.008.013.007c.035.019.1.054.163.092l.017.01.005.004.06.033.005.003.018.01.019.011.034.02.186.107c.07.04.176.103.255.159.084.051.177.116.239.158l.03.021c.047.03.094.062.126.085l.202.15.07.052.065.048.016.011.017.013a10 10 0 0 1 1.898 1.9v.001l.001.001a9.6 9.6 0 0 1 1.407 2.613c.981 2.743.693 5.91-.85 8.412l-1.105-.682c1.33-2.155 1.586-4.91.732-7.294v-.002l-.001-.002a8.3 8.3 0 0 0-1.217-2.26 8.8 8.8 0 0 0-1.638-1.645l-.104-.075-.078-.059-.052-.04-.119-.087-.042-.03-.019-.01-.022-.015-.09-.062a3 3 0 0 0-.155-.103l-.032-.019-.03-.022q.003.002 0 0l-.044-.028-.116-.07-.161-.092-.054-.031-.001-.001-.01-.006-.006-.003-.091-.051-.051-.031-.088-.05h-.002l-.144-.069-.289-.136-.008-.004-.035-.018a7 7 0 0 0-.434-.175l-.11-.041q-.15-.054-.316-.104m1.586.689zm0 0z" transform="translate(-24 -24)"/><path d="M31.494 29.226a8.33 8.33 0 0 0 5.002-3.016l1.018.805a9.63 9.63 0 0 1-5.79 3.488 9.6 9.6 0 0 1-3.434.003 9.5 9.5 0 0 1-3.216-1.204l.668-1.112a8.2 8.2 0 0 0 2.778 1.038h.003c.977.18 1.984.175 2.97-.003m-3.467 9.353c1.108-5.654 6.87-8.777 11.583-7.492l-.342 1.252c-4-1.09-9.007 1.59-9.967 6.49z" transform="translate(-24 -24)"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
web/icons/edit.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><g><path d="M13.33.59c-.55 0-1.08.22-1.47.6L5.53 7.54a.67.67 0 0 0-.18.3l-.66 2.68a.66.66 0 0 0 .8.8l2.67-.66a.66.66 0 0 0 .31-.18l6.33-6.33A2.08 2.08 0 0 0 13.33.59Zm-.53 1.55a.75.75 0 1 1 1.06 1.06l-6.2 6.2-1.41.35.35-1.41 6.2-6.2Z" clip-rule="evenodd"/><path d="M2.67 2a2 2 0 0 0-2 2v9.33a2 2 0 0 0 2 2H12a2 2 0 0 0 2-2V8.67a.66.66 0 1 0-1.33 0v4.66A.67.67 0 0 1 12 14H2.67a.67.67 0 0 1-.67-.67V4a.67.67 0 0 1 .67-.67h4.66a.66.66 0 1 0 0-1.33H2.67Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 547 B |
3
web/icons/equal.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M2.668 6c0-.367.298-.665.665-.665h9.334a.665.665 0 0 1 0 1.33H3.333A.665.665 0 0 1 2.668 6Zm0 4c0-.367.298-.665.665-.665h9.334a.665.665 0 0 1 0 1.33H3.333A.665.665 0 0 1 2.668 10Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 285 B |
1
web/icons/exclamation-circle.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM11 7H13V13H11V7Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 346 B |
10
web/icons/expand-both-diagonals.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m 15,14 a 1,1 0 0 0 -0.707031,0.292969 1,1 0 0 0 0,1.414062 l 6,6 a 1,1 0 0 0 1.414062,0 1,1 0 0 0 0,-1.414062 l -6,-6 A 1,1 0 0 0 15,14 Z" />
|
||||
<path d="m 20.292969,2.2929688 -6,6 a 1,1 0 0 0 0,1.4140625 1,1 0 0 0 1.414062,0 l 6,-6 a 1,1 0 0 0 0,-1.4140625 1,1 0 0 0 -1.414062,0 z" />
|
||||
<path d="m 21,15.199219 a 1,1 0 0 0 -1,1 V 20 h -3.800781 a 1,1 0 0 0 -1,1 1,1 0 0 0 1,1 H 21 a 1.0001,1.0001 0 0 0 1,-1 v -4.800781 a 1,1 0 0 0 -1,-1 z" />
|
||||
<path d="m 16.199219,2 a 1,1 0 0 0 -1,1 1,1 0 0 0 1,1 H 20 v 3.8007812 a 1,1 0 0 0 1,1.0000001 1,1 0 0 0 1,-1.0000001 V 3 A 1.0001,1.0001 0 0 0 21,2 Z" />
|
||||
<path d="m 3,15.199219 a 1,1 0 0 0 -1,1 V 21 a 1.0001,1.0001 0 0 0 1,1 H 7.8007812 A 1,1 0 0 0 8.8007813,21 1,1 0 0 0 7.8007812,20 H 4 v -3.800781 a 1,1 0 0 0 -1,-1 z" />
|
||||
<path d="m 9,14 a 1,1 0 0 0 -0.7070312,0.292969 l -6,6 a 1,1 0 0 0 0,1.414062 1,1 0 0 0 1.4140625,0 l 6,-6 a 1,1 0 0 0 0,-1.414062 A 1,1 0 0 0 9,14 Z" />
|
||||
<path d="M 3,2 A 1.0001,1.0001 0 0 0 2,3 V 7.8007812 A 1,1 0 0 0 3,8.8007813 1,1 0 0 0 4,7.8007812 V 4 H 7.8007812 A 1,1 0 0 0 8.8007813,3 1,1 0 0 0 7.8007812,2 Z" />
|
||||
<path d="m 3,2 a 1,1 0 0 0 -0.7070312,0.2929688 1,1 0 0 0 0,1.4140625 l 6,6 a 1,1 0 0 0 1.4140625,0 1,1 0 0 0 0,-1.4140625 l -6,-6 A 1,1 0 0 0 3,2 Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
web/icons/expand-diagonal.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="m8.34 3-.01-.01c0-.36.29-.66.66-.66h4c.37 0 .67.29.67.67v4a.67.67 0 0 1-1.33 0V4.61L9.79 7.14a.665.665 0 0 1-.94-.94l2.55-2.53H9c-.36 0-.66-.3-.66-.67ZM3.67 9v2.4l2.54-2.54a.665.665 0 0 1 .94.94L4.6 12.34H7a.67.67 0 0 1 0 1.33H3c-.36 0-.66-.3-.66-.67V9a.67.67 0 0 1 1.33 0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 374 B |
4
web/icons/expand.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M4.195 9.529c.26-.26.683-.26.943 0L8 12.39l2.862-2.861a.667.667 0 1 1 .943.942L8.47 13.805a.667.667 0 0 1-.942 0L4.195 10.47a.667.667 0 0 1 0-.942Zm3.334-7.334c.26-.26.682-.26.942 0l3.334 3.334a.667.667 0 1 1-.943.942L8 3.61 5.138 6.471a.667.667 0 0 1-.943-.942L7.53 2.195Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 389 B |
4
web/icons/external-link.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14">
|
||||
<path d="M2.33333 3.6665C2.15647 3.6665 1.98686 3.73675 1.86181 3.86181C1.73675 3.98686 1.6665 4.15647 1.6665 4.33333V11.6667C1.6665 11.8435 1.73675 12.0131 1.86181 12.1382C1.98686 12.2632 2.15647 12.3335 2.33333 12.3335H9.66666C9.84352 12.3335 10.0131 12.2632 10.1382 12.1382C10.2632 12.0131 10.3335 11.8435 10.3335 11.6667V7.66666C10.3335 7.29856 10.6319 7.00016 11 7.00016C11.3681 7.00016 11.6665 7.29856 11.6665 7.66666V11.6667C11.6665 12.1971 11.4558 12.7057 11.0808 13.0808C10.7057 13.4558 10.1971 13.6665 9.66666 13.6665H2.33333C1.80294 13.6665 1.29427 13.4558 0.919234 13.0808C0.544192 12.7057 0.333496 12.1971 0.333496 11.6667V4.33333C0.333496 3.80294 0.544192 3.29428 0.919234 2.91923C1.29428 2.54419 1.80294 2.3335 2.33333 2.3335H6.33333C6.70143 2.3335 6.99983 2.6319 6.99983 3C6.99983 3.36809 6.70143 3.6665 6.33333 3.6665H2.33333Z"/>
|
||||
<path d="M8.3335 0.999996C8.3335 0.631898 8.6319 0.333496 9 0.333496H13C13.3681 0.333496 13.6665 0.631898 13.6665 0.999996V5C13.6665 5.36809 13.3681 5.6665 13 5.6665C12.6319 5.6665 12.3335 5.36809 12.3335 5V2.60907L6.13795 8.80462C5.87766 9.0649 5.45566 9.0649 5.19538 8.80462C4.93509 8.54433 4.93509 8.12233 5.19538 7.86204L11.3909 1.6665H9C8.6319 1.6665 8.3335 1.36809 8.3335 0.999996Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
3
web/icons/eye.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M8 4c2.527 0 4.78 1.42 5.88 3.667A6.513 6.513 0 0 1 8 11.333a6.513 6.513 0 0 1-5.88-3.666A6.513 6.513 0 0 1 8 4Zm0-1.333c-3.333 0-6.18 2.073-7.333 5 1.153 2.926 4 5 7.333 5s6.18-2.074 7.333-5c-1.153-2.927-4-5-7.333-5ZM8 6a1.667 1.667 0 1 1-.001 3.335A1.667 1.667 0 0 1 8 6Zm0-1.333c-1.653 0-3 1.346-3 3 0 1.653 1.347 3 3 3s3-1.347 3-3c0-1.654-1.347-3-3-3Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 460 B |
1
web/icons/f-globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11.280 1.023 C 11.203 1.032,10.951 1.059,10.720 1.083 C 8.832 1.283,6.991 2.023,5.360 3.235 C 4.751 3.688,3.684 4.755,3.226 5.367 C 0.728 8.714,0.301 13.032,2.097 16.780 C 2.643 17.918,3.276 18.801,4.238 19.762 C 5.199 20.724,6.082 21.357,7.220 21.903 C 10.967 23.699,15.288 23.270,18.637 20.770 C 19.246 20.316,20.316 19.246,20.770 18.637 C 23.744 14.652,23.744 9.348,20.770 5.363 C 20.316 4.755,19.247 3.686,18.640 3.235 C 17.045 2.049,15.365 1.360,13.423 1.096 C 13.000 1.038,11.574 0.990,11.280 1.023 M9.642 3.410 C 8.325 5.415,7.388 7.990,7.120 10.340 C 7.095 10.560,7.066 10.798,7.055 10.870 L 7.036 11.000 5.052 11.000 L 3.069 11.000 3.094 10.761 C 3.208 9.665,3.773 8.137,4.440 7.120 C 5.573 5.393,7.229 4.110,9.160 3.463 C 9.457 3.364,9.706 3.282,9.714 3.281 C 9.722 3.281,9.689 3.339,9.642 3.410 M15.300 3.629 C 17.009 4.297,18.520 5.535,19.560 7.120 C 20.227 8.137,20.792 9.665,20.906 10.761 L 20.931 11.000 18.948 11.000 L 16.964 11.000 16.945 10.870 C 16.935 10.798,16.888 10.452,16.842 10.100 C 16.612 8.352,16.090 6.661,15.281 5.040 C 15.013 4.505,14.587 3.753,14.346 3.392 L 14.274 3.285 14.607 3.387 C 14.790 3.443,15.102 3.552,15.300 3.629 M12.268 3.913 C 13.067 4.991,13.860 6.551,14.305 7.920 C 14.579 8.761,14.836 9.955,14.903 10.691 L 14.931 11.000 12.000 11.000 L 9.069 11.000 9.097 10.691 C 9.256 8.935,9.994 6.728,10.981 5.060 C 11.380 4.385,11.960 3.555,12.010 3.586 C 12.027 3.597,12.143 3.743,12.268 3.913 M7.055 13.130 C 7.066 13.202,7.095 13.440,7.120 13.660 C 7.387 16.008,8.326 18.588,9.641 20.587 L 9.725 20.715 9.392 20.614 C 7.358 19.993,5.625 18.686,4.440 16.880 C 3.773 15.863,3.208 14.335,3.094 13.239 L 3.069 13.000 5.052 13.000 L 7.036 13.000 7.055 13.130 M14.903 13.309 C 14.811 14.324,14.454 15.770,14.021 16.880 C 13.568 18.039,12.626 19.712,12.066 20.349 L 11.988 20.438 11.753 20.117 C 10.889 18.938,10.153 17.494,9.694 16.080 C 9.421 15.235,9.164 14.043,9.097 13.309 L 9.069 13.000 12.000 13.000 L 14.931 13.000 14.903 13.309 M20.906 13.239 C 20.825 14.017,20.498 15.087,20.082 15.940 C 18.982 18.194,16.995 19.890,14.607 20.614 L 14.274 20.715 14.346 20.608 C 14.583 20.252,15.008 19.503,15.278 18.964 C 16.089 17.343,16.611 15.651,16.842 13.900 C 16.888 13.548,16.935 13.202,16.945 13.130 L 16.964 13.000 18.948 13.000 L 20.931 13.000 20.906 13.239 " ></path></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
1
web/icons/facebook.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M16 8.03C16 3.594 12.418 0 8 0S0 3.595 0 8.03a8.03 8.03 0 0 0 6.067 7.792v-5.339h-1.65V8.03h1.65V6.972c0-2.733 1.233-4 3.906-4 .507 0 1.381.1 1.739.2v2.224c-.189-.02-.517-.03-.924-.03-1.312 0-1.819.499-1.819 1.796v.867h2.613l-.449 2.454H8.97V16c3.96-.48 7.03-3.865 7.03-7.97"/></svg>
|
||||
|
After Width: | Height: | Size: 376 B |
3
web/icons/file-check.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M2.587 1.254A1.998 1.998 0 0 1 4 .668h6c.176 0 .345.07.47.195l3.334 3.333a.665.665 0 0 1 .194.47v8.667A1.998 1.998 0 0 1 12 15.332H4a1.999 1.999 0 0 1-1.998-1.999V2.667c0-.53.21-1.039.585-1.413Zm9.807 3.414L9.998 2.272V4a.668.668 0 0 0 .669.668h1.728Zm-3.726-2.67H4a.668.668 0 0 0-.668.669v10.666a.668.668 0 0 0 .668.669h8a.668.668 0 0 0 .668-.669V5.998h-2.001A1.998 1.998 0 0 1 8.668 4V1.998Zm1.802 6.198c.26.26.26.681 0 .94l-2.666 2.668a.664.664 0 0 1-.94 0L5.53 10.47a.665.665 0 1 1 .94-.94l.863.863L9.53 8.196c.26-.26.68-.26.94 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 639 B |
1
web/icons/file-text.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M3.640 0.707 C 2.977 0.831,2.394 1.307,2.152 1.920 C 2.005 2.294,2.013 1.927,2.013 8.000 C 2.013 14.073,2.005 13.706,2.152 14.080 C 2.341 14.560,2.771 14.989,3.253 15.181 C 3.624 15.328,3.365 15.320,8.000 15.320 C 11.487 15.320,12.332 15.313,12.440 15.285 C 13.174 15.090,13.676 14.615,13.924 13.880 L 13.987 13.693 13.994 9.469 C 14.000 5.625,13.997 5.232,13.958 5.109 C 13.917 4.983,13.765 4.825,11.804 2.862 C 9.753 0.811,9.689 0.750,9.548 0.709 C 9.420 0.672,9.084 0.667,6.615 0.669 C 4.429 0.671,3.786 0.679,3.640 0.707 M8.667 3.715 C 8.667 5.651,8.661 5.596,8.866 5.801 C 9.071 6.005,9.015 6.000,10.951 6.000 L 12.667 6.000 12.665 9.727 C 12.665 12.770,12.658 13.472,12.629 13.553 C 12.576 13.701,12.468 13.829,12.326 13.913 L 12.200 13.987 8.000 13.987 L 3.800 13.987 3.672 13.912 C 3.527 13.826,3.399 13.665,3.360 13.519 C 3.342 13.453,3.335 11.555,3.340 7.943 L 3.347 2.467 3.421 2.341 C 3.504 2.199,3.632 2.091,3.780 2.038 C 3.861 2.009,4.344 2.002,6.273 2.001 L 8.667 2.000 8.667 3.715 M10.867 3.813 L 11.720 4.667 10.860 4.667 L 10.000 4.667 10.000 3.813 C 10.000 3.344,10.003 2.960,10.007 2.960 C 10.011 2.960,10.398 3.344,10.867 3.813 M5.116 5.375 C 4.870 5.451,4.667 5.734,4.667 6.000 C 4.667 6.264,4.861 6.539,5.107 6.623 C 5.298 6.688,6.702 6.688,6.893 6.623 C 7.139 6.539,7.333 6.264,7.333 6.000 C 7.333 5.727,7.130 5.450,6.873 5.374 C 6.682 5.317,5.301 5.318,5.116 5.375 M5.116 8.042 C 4.870 8.118,4.667 8.401,4.667 8.667 C 4.667 8.828,4.746 9.014,4.866 9.134 C 5.077 9.345,4.891 9.333,8.000 9.333 C 11.109 9.333,10.923 9.345,11.134 9.134 C 11.398 8.870,11.395 8.456,11.127 8.195 C 10.915 7.989,11.082 8.000,7.988 8.002 C 5.680 8.003,5.220 8.010,5.116 8.042 M5.116 10.709 C 4.870 10.784,4.667 11.067,4.667 11.333 C 4.667 11.494,4.746 11.681,4.866 11.801 C 5.077 12.012,4.891 12.000,8.000 12.000 C 11.109 12.000,10.923 12.012,11.134 11.801 C 11.398 11.536,11.395 11.123,11.127 10.862 C 10.915 10.656,11.082 10.666,7.988 10.669 C 5.680 10.670,5.220 10.677,5.116 10.709 " ></path></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
4
web/icons/flag.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M 8,1 C 6.485551,1 5.0123405,1.4917212 3.8007813,2.4003906 3.2978711,2.7775732 3,3.3713624 3,4 v 18 a 1,1 0 0 0 1,1 1,1 0 0 0 1,-1 V 16.070312 C 5.8675934,15.409892 6.9022918,15.000028 8,15 c 1.2777778,0 2.378906,0.427734 3.628906,0.927734 C 12.878906,16.427734 14.277778,17 16,17 17.514449,17 18.98766,16.508279 20.199219,15.599609 20.702129,15.222427 21,14.628638 21,14 V 4 C 21,2.4148608 19.068893,1.4493071 17.800781,2.4003906 17.329379,2.7538319 16.532031,3 15.332031,3 14.451286,3 13.493028,2.5868738 12.310547,2.0800781 11.128066,1.5732824 9.7222222,1 8,1 Z M 8,3 C 9.2777778,3 10.372668,3.4267176 11.523438,3.9199219 12.674205,4.4131262 13.879776,5 15.332031,5 16.798698,5 18.048736,4.7132254 19,4 v 10 c -0.865595,0.649196 -1.918006,1 -3,1 -1.277778,0 -2.378906,-0.427734 -3.628906,-0.927734 C 11.121094,13.572266 9.7222222,13 8,13 6.9398125,13.000027 5.9404047,13.31454 5,13.767578 V 4 C 5.8655948,3.3508039 6.9180065,3 8,3 Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
6
web/icons/folder-chevron.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M 4,2 C 2.3549904,2 1,3.3549904 1,5 v 13 c 0,1.64501 1.3549904,3 3,3 h 6.300781 a 1,1 0 0 0 1,-1 1,1 0 0 0 -1,-1 H 4 C 3.4358706,19 3,18.564129 3,18 V 5 C 3,4.4358706 3.4358706,4 4,4 h 3.9804687 a 1.0001,1.0001 0 0 0 0.00977,0 c 0.3385977,-0.00332 0.6498014,0.1642012 0.8378906,0.4453125 l 0.625,1.1367187 a 1.0001,1.0001 0 0 0 0.041016,0.068359 C 10.048363,6.4919664 10.992324,6.9998341 12,7 h 8 c 0.564129,0 1,0.4358706 1,1 v 3.300781 a 1,1 0 0 0 1,1 1,1 0 0 0 1,-1 V 8 C 23,6.3549904 21.64501,5 20,5 H 12 C 11.665002,4.9999448 11.357974,4.8327914 11.171875,4.5546875 l -0.625,-1.1367187 a 1.0001,1.0001 0 0 0 -0.04102,-0.068359 C 9.9458514,2.4991412 8.9889395,1.990017 7.9707031,2 Z" />
|
||||
<path
|
||||
d="m 13.105469,14.875 a 1,1 0 0 0 -0.707031,0.292969 1,1 0 0 0 0,1.414062 l 4.585937,4.585938 a 1.0001,1.0001 0 0 0 1.410156,0.0039 l 4.644531,-4.597656 a 1,1 0 0 0 0.0078,-1.414063 1,1 0 0 0 -1.414063,-0.0059 l -3.9375,3.896484 L 13.8125,15.167969 A 1,1 0 0 0 13.105469,14.875 Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
3
web/icons/folder-cog.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m 18,14 c -2.197294,0 -4,1.802706 -4,4 0,2.197294 1.802706,4 4,4 2.197294,0 4,-1.802706 4,-4 0,-2.197294 -1.802706,-4 -4,-4 z m 0,2 c 1.116414,0 2,0.883586 2,2 0,1.116414 -0.883586,2 -2,2 -1.116414,0 -2,-0.883586 -2,-2 0,-1.116414 0.883586,-2 2,-2 z m 3.960937,2.558594 c -0.09713,0.684939 -0.370525,1.316189 -0.771484,1.845703 l 0.123047,0.05078 a 1,1 0 0 0 1.306641,-0.541016 1,1 0 0 0 -0.541016,-1.30664 z M 19.875,18.703125 a 1,1 0 0 0 -0.02734,0.0625 1,1 0 0 0 -0.02734,0.07031 c 0.01975,-0.04359 0.03802,-0.08755 0.05469,-0.132812 z M 21.695312,15.46875 A 1,1 0 0 0 21.3125,15.54492 l -0.123047,0.05078 c 0.400959,0.529514 0.674358,1.160764 0.771484,1.845703 l 0.117188,-0.04883 a 1,1 0 0 0 0.541016,-1.306641 1,1 0 0 0 -0.923829,-0.617187 z m -1.875,1.695312 a 1,1 0 0 0 0.02734,0.07031 1,1 0 0 0 0.02734,0.0625 c -0.01666,-0.04527 -0.03494,-0.08922 -0.05469,-0.132813 z m -0.986328,2.658204 a 1,1 0 0 0 -0.06836,0.02539 1,1 0 0 0 -0.06055,0.02734 c 0.04342,-0.01608 0.08703,-0.03381 0.128906,-0.05273 z m 1.570313,1.367187 c -0.529766,0.401082 -1.162324,0.67459 -1.847656,0.771484 l 0.04883,0.117188 a 1,1 0 0 0 1.30664,0.542969 1,1 0 0 0 0.542969,-1.306641 z M 19.53125,13.304688 a 1,1 0 0 0 -0.382813,0.07617 1,1 0 0 0 -0.541015,0.541016 l -0.04883,0.117188 c 0.684977,0.09713 1.316225,0.370461 1.845703,0.771484 L 20.455078,14.6875 A 1,1 0 0 0 19.914062,13.380859 1,1 0 0 0 19.53125,13.304688 Z M 18.705078,16.125 a 1,1 0 0 0 0.06055,0.02734 1,1 0 0 0 0.06836,0.02539 c -0.04188,-0.01892 -0.08549,-0.03666 -0.128906,-0.05273 z m -1.541016,3.695312 c 0.04359,0.01975 0.08755,0.03802 0.132813,0.05469 a 1,1 0 0 0 -0.0625,-0.02734 1,1 0 0 0 -0.07031,-0.02734 z m -1.568359,1.369141 -0.05078,0.123047 a 1,1 0 0 0 0.541015,1.306641 1,1 0 0 0 1.306641,-0.541016 l 0.04883,-0.117188 C 16.75647,21.863807 16.12522,21.590412 15.595706,21.189453 Z m 0.873047,-7.884765 a 1,1 0 0 0 -0.382813,0.07617 1,1 0 0 0 -0.541015,1.306641 l 0.05078,0.123047 c 0.529514,-0.400959 1.160764,-0.674358 1.845703,-0.771484 l -0.04883,-0.117188 A 1,1 0 0 0 16.46875,13.304688 Z M 17.296875,16.125 c -0.04527,0.01666 -0.08922,0.03494 -0.132813,0.05469 a 1,1 0 0 0 0.07031,-0.02734 1,1 0 0 0 0.0625,-0.02734 z m -2.992187,-0.65625 a 1,1 0 0 0 -0.923829,0.617187 1,1 0 0 0 0.541016,1.306641 l 0.117188,0.04883 c 0.09713,-0.684977 0.370461,-1.316225 0.771484,-1.845703 L 14.6875,15.544922 a 1,1 0 0 0 -0.382812,-0.07617 z m 1.873046,1.697266 c -0.01892,0.04188 -0.03666,0.08549 -0.05273,0.128906 a 1,1 0 0 0 0.02734,-0.06055 1,1 0 0 0 0.02539,-0.06836 z m -2.138671,1.390625 -0.117188,0.04883 a 1,1 0 0 0 -0.541016,1.30664 1,1 0 0 0 1.306641,0.541016 l 0.121094,-0.05078 C 14.407791,19.872716 14.135915,19.241683 14.039063,18.556641 Z M 16.125,18.705078 c 0.01608,0.04343 0.03381,0.08703 0.05273,0.128906 A 1,1 0 0 0 16.15234,18.765624 1,1 0 0 0 16.125,18.705074 Z M 4,2 C 2.3549904,2 1,3.3549904 1,5 v 13 c 0,1.64501 1.3549904,3 3,3 h 6.300781 a 1,1 0 0 0 1,-1 1,1 0 0 0 -1,-1 H 4 C 3.4358706,19 3,18.564129 3,18 V 5 C 3,4.4358706 3.4358706,4 4,4 h 3.9804687 a 1.0001,1.0001 0 0 0 0.00977,0 C 8.3288321,3.99668 8.6400358,4.1642012 8.828125,4.4453125 l 0.625,1.1367187 a 1.0001,1.0001 0 0 0 0.041016,0.068359 C 10.048358,6.4919668 10.992324,6.9998341 12,7 h 8 c 0.564129,0 1,0.4358706 1,1 v 3.300781 a 1,1 0 0 0 1,1 1,1 0 0 0 1,-1 V 8 C 23,6.3549904 21.64501,5 20,5 H 12 C 11.665002,4.9999448 11.357974,4.8327914 11.171875,4.5546875 l -0.625,-1.1367187 a 1.0001,1.0001 0 0 0 -0.04102,-0.068359 C 9.9458558,2.4991408 8.9889395,1.990017 7.9707031,2 Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
12
web/icons/folder-search.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M 4,2 C 2.354992,2 1,3.354992 1,5 v 13 c 0,1.645008 1.354992,3 3,3 h 6.699219 a 1,1 0 0 0 1,-1 1,1 0 0 0 -1,-1 H 4 C 3.4358712,19 3,18.564129 3,18 V 5 C 3,4.4358712 3.4358712,4 4,4 h 3.9003906 a 1.0001,1.0001 0 0 0 0.00977,0 c 0.3410981,-0.00334 0.6561535,0.1643212 0.8437501,0.4492187 a 1.0001,1.0001 0 0 0 0.00781,0.00977 L 9.5644531,5.6503906 C 10.118675,6.4919656 11.062637,6.9998341 12.070313,7 H 20 c 0.564129,0 1,0.4358712 1,1 v 4.099609 a 1,1 0 0 0 1,1 1,1 0 0 0 1,-1 V 8 C 23,6.354992 21.645008,5 20,5 H 12.070313 C 11.732817,4.9999444 11.419998,4.8326458 11.234375,4.5507812 a 1.0001,1.0001 0 0 0 -0.0059,-0.00977 L 10.425781,3.3496094 c -7.94e-4,-0.00121 -0.0031,-7.486e-4 -0.0039,-0.00195 C 9.8615388,2.4993607 8.9074166,1.9900312 7.890625,2 Z" />
|
||||
<path
|
||||
d="m 18.392578,18.392578 a 1,1 0 0 0 0,1.414063 l 1.900391,1.90039 a 1,1 0 0 0 1.414062,0 1,1 0 0 0 0,-1.414062 l -1.90039,-1.900391 a 1,1 0 0 0 -1.414063,0 z" />
|
||||
<path
|
||||
d="m 17,13 c -2.197292,0 -4,1.802708 -4,4 0,2.197292 1.802708,4 4,4 2.197292,0 4,-1.802708 4,-4 0,-2.197292 -1.802708,-4 -4,-4 z m 0,2 c 1.116413,0 2,0.883587 2,2 0,1.116413 -0.883587,2 -2,2 -1.116413,0 -2,-0.883587 -2,-2 0,-1.116413 0.883587,-2 2,-2 z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
8
web/icons/folder.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M 4,2 C 2.354992,2 1,3.354992 1,5 v 13 c 0,1.645008 1.354992,3 3,3 h 16 c 1.645008,0 3,-1.354992 3,-3 V 8 C 23,6.354992 21.645008,5 20,5 h -7.900391 a 1.0001,1.0001 0 0 0 -0.0098,0 c -0.341098,0.00334 -0.656154,-0.1643212 -0.84375,-0.4492188 a 1.0001,1.0001 0 0 0 -0.0078,-0.00977 L 10.435547,3.3496094 C 9.8813254,2.5080344 8.937363,2.0001659 7.9296875,2 Z m 0,2 h 3.9296875 c 0.3374958,5.56e-5 0.6503148,0.1673542 0.8359375,0.4492187 a 1.0001,1.0001 0 0 0 0.00586,0.00977 l 0.8027344,1.1914062 c 7.94e-4,0.00121 0.00311,7.486e-4 0.00391,0.00195 C 10.138461,6.5006393 11.092583,7.0099688 12.109375,7 H 20 c 0.564129,0 1,0.4358712 1,1 v 10 c 0,0.564129 -0.435871,1 -1,1 H 4 C 3.4358712,19 3,18.564129 3,18 V 5 C 3,4.4358712 3.4358712,4 4,4 Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 865 B |
1
web/icons/follow.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M10.953 1.604a.665.665 0 0 1 1.022-.852C13.679 2.797 14.69 5.295 14.69 8c0 2.706-1.012 5.204-2.716 7.248a.665.665 0 1 1-1.022-.851C12.479 12.565 13.36 10.362 13.36 8c0-2.361-.88-4.564-2.408-6.396Zm-2.727 1.94c2.423 2.423 2.407 6.4.01 8.901a.75.75 0 0 1-1.082-1.038c1.853-1.933 1.837-4.977.011-6.802a.75.75 0 0 1 1.06-1.061ZM2 8a1.848 1.848 0 1 1 3.695 0A1.848 1.848 0 0 1 2 8Z"/></svg>
|
||||
|
After Width: | Height: | Size: 477 B |
1
web/icons/forward-message.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><g><path d="M8.378 7.008c-.255.54-.343 1.296-.075 2.365a.665.665 0 11-1.29.324c-.321-1.279-.258-2.366.163-3.257.422-.892 1.16-1.498 2.017-1.893 1.177-.544 2.646-.725 4.08-.708l-1.26-1.26a.665.665 0 11.94-.94l2.51 2.508a.663.663 0 01-.016.956l-2.493 2.494a.665.665 0 01-.94-.94l1.482-1.483c-1.39-.04-2.744.118-3.745.58-.654.302-1.117.715-1.373 1.254z"/><path d="M8.335 1.67a.665.665 0 110 1.33H2.833a.668.668 0 00-.668.668V13.73l1.531-1.531a.665.665 0 01.47-.195h9a.668.668 0 00.669-.668V9.083a.665.665 0 111.33 0v2.252a1.998 1.998 0 01-1.998 1.998H4.442L1.97 15.805a.665.665 0 01-1.135-.47V3.668A1.998 1.998 0 012.833 1.67h5.502z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 705 B |
1
web/icons/gear.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g><path d="M9 7.665a1.335 1.335 0 1 0 0 2.67 1.335 1.335 0 0 0 0-2.67ZM6.335 9a2.665 2.665 0 1 1 5.33 0 2.665 2.665 0 0 1-5.33 0Z"/><path d="M9 1.665a.79.79 0 0 0-.79.79v.126a1.865 1.865 0 0 1-1.13 1.707.665.665 0 0 1-.182.049 1.865 1.865 0 0 1-1.927-.425l-.005-.005-.043-.044A.792.792 0 1 0 3.805 4.98l.049.05a1.865 1.865 0 0 1 .381 2.037 1.865 1.865 0 0 1-1.7 1.201h-.08a.79.79 0 1 0 0 1.58h.126a1.865 1.865 0 0 1 1.705 1.127 1.865 1.865 0 0 1-.374 2.054l-.005.005-.044.043a.793.793 0 0 0 0 1.118.793.793 0 0 0 .861.172.79.79 0 0 0 .256-.172l.05-.049a1.866 1.866 0 0 1 2.037-.38 1.865 1.865 0 0 1 1.201 1.698v.082a.79.79 0 0 0 1.58 0v-.127a1.865 1.865 0 0 1 1.127-1.705 1.865 1.865 0 0 1 2.054.374l.005.005.043.044a.791.791 0 1 0 1.118-1.117l-.049-.05a1.865 1.865 0 0 1-.374-2.053 1.865 1.865 0 0 1 1.705-1.127h.069a.79.79 0 0 0 0-1.58h-.127a1.865 1.865 0 0 1-1.707-1.13.664.664 0 0 1-.048-.182 1.865 1.865 0 0 1 .424-1.927l.005-.005.044-.043a.79.79 0 1 0-1.117-1.118l-.05.049a1.865 1.865 0 0 1-2.053.374A1.865 1.865 0 0 1 9.79 2.523v-.068a.79.79 0 0 0-.79-.79ZM7.501.955a2.12 2.12 0 0 1 3.619 1.5v.063a.535.535 0 0 0 .324.489l.006.003a.535.535 0 0 0 .588-.105l.04-.04a2.12 2.12 0 1 1 3 2.998l-.041.04a.535.535 0 0 0-.105.589.72.72 0 0 1 .045.143.535.535 0 0 0 .446.245h.123a2.12 2.12 0 0 1 0 4.24h-.064a.535.535 0 0 0-.489.324l-.003.006a.536.536 0 0 0 .105.588l.04.04a2.12 2.12 0 1 1-2.998 3l-.04-.041a.535.535 0 0 0-.588-.105l-.007.003a.535.535 0 0 0-.324.489v.122a2.12 2.12 0 0 1-4.24 0v-.057a.535.535 0 0 0-.35-.483.681.681 0 0 1-.038-.016.535.535 0 0 0-.588.105l-.04.04a2.12 2.12 0 1 1-3-2.998l.041-.04a.535.535 0 0 0 .105-.588l-.003-.007a.535.535 0 0 0-.488-.324h-.122a2.12 2.12 0 1 1 0-4.24h.056a.535.535 0 0 0 .483-.35.673.673 0 0 1 .016-.038.535.535 0 0 0-.105-.588l-.04-.04a2.12 2.12 0 1 1 2.998-3l.04.041a.535.535 0 0 0 .589.105.665.665 0 0 1 .143-.045.535.535 0 0 0 .245-.446v-.122c0-.563.224-1.102.621-1.5Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
1
web/icons/gif.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22"><g clip-rule="evenodd"><path d="M7.054 14.557C5.16 14.557 4 13.322 4 11.26 4 9.23 5.16 8 7.023 8c1.218 0 2.171.571 2.527 1.35a.89.89 0 0 1 .097.408c0 .338-.233.571-.58.571-.281 0-.48-.119-.642-.387-.312-.567-.773-.843-1.397-.843-1.046 0-1.674.795-1.674 2.144 0 1.376.667 2.215 1.722 2.215.866 0 1.468-.532 1.494-1.318l.005-.101h-.923c-.317 0-.532-.18-.532-.484 0-.303.211-.483.532-.483h1.551c.418 0 .664.255.664.69v.096c0 1.613-1.024 2.699-2.813 2.699Zm4.986-.022c-.418 0-.664-.255-.664-.69V8.716c0-.44.246-.694.663-.694.418 0 .664.255.664.694v5.129c0 .435-.246.69-.664.69Zm3.675-.69c0 .435-.246.69-.664.69-.418 0-.664-.255-.664-.69V8.8c0-.435.246-.69.664-.69h2.966c.365 0 .602.206.602.55 0 .342-.241.549-.602.549h-2.302v1.696h2.087c.343 0 .567.197.567.523 0 .33-.22.527-.567.527h-2.087v1.89Z"/><path d="M3 3.336A2.665 2.665 0 0 0 .337 6v10A2.665 2.665 0 0 0 3 18.666h16A2.665 2.665 0 0 0 21.666 16V6A2.665 2.665 0 0 0 19 3.336H3ZM1.667 6c0-.737.598-1.335 1.335-1.335h16c.737 0 1.335.598 1.335 1.335v10c0 .737-.598 1.335-1.335 1.335H3A1.335 1.335 0 0 1 1.666 16V6Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
web/icons/git-pull-request.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M3.635 1.360 C 3.255 1.413,2.812 1.588,2.477 1.815 C 1.966 2.162,1.568 2.746,1.406 3.387 C 1.322 3.719,1.324 4.293,1.410 4.627 C 1.482 4.907,1.652 5.282,1.815 5.523 C 2.114 5.964,2.618 6.341,3.147 6.521 L 3.320 6.580 3.333 10.377 L 3.347 14.173 3.409 14.300 C 3.554 14.595,3.899 14.734,4.217 14.626 C 4.380 14.570,4.516 14.452,4.591 14.300 L 4.653 14.173 4.667 10.377 L 4.680 6.580 4.853 6.521 C 5.382 6.341,5.886 5.964,6.185 5.523 C 6.348 5.282,6.518 4.906,6.590 4.627 C 6.672 4.308,6.678 3.747,6.604 3.427 C 6.455 2.785,6.040 2.166,5.523 1.815 C 5.283 1.653,4.906 1.482,4.640 1.415 C 4.381 1.351,3.889 1.324,3.635 1.360 M4.272 2.696 C 4.873 2.822,5.333 3.387,5.333 4.000 C 5.333 4.721,4.722 5.333,4.000 5.333 C 3.278 5.333,2.667 4.721,2.667 4.000 C 2.667 3.401,3.105 2.846,3.689 2.705 C 3.865 2.662,4.092 2.659,4.272 2.696 M8.449 3.374 C 8.203 3.452,8.000 3.734,8.000 4.000 C 8.000 4.241,8.152 4.485,8.367 4.591 C 8.492 4.653,8.503 4.653,9.680 4.667 L 10.867 4.680 10.994 4.755 C 11.139 4.840,11.267 5.001,11.307 5.148 C 11.324 5.211,11.331 6.000,11.327 7.333 L 11.320 9.420 11.147 9.479 C 10.606 9.663,10.108 10.043,9.788 10.515 C 9.651 10.719,9.477 11.112,9.410 11.373 C 9.324 11.707,9.322 12.281,9.406 12.613 C 9.659 13.615,10.428 14.373,11.427 14.604 C 11.720 14.672,12.280 14.672,12.573 14.604 C 13.582 14.370,14.370 13.582,14.604 12.573 C 14.678 12.253,14.672 11.692,14.590 11.373 C 14.523 11.113,14.350 10.719,14.212 10.515 C 13.892 10.043,13.394 9.663,12.853 9.479 L 12.680 9.420 12.666 7.197 L 12.653 4.973 12.590 4.787 C 12.556 4.684,12.493 4.529,12.450 4.443 C 12.210 3.958,11.747 3.576,11.199 3.410 C 11.007 3.352,10.958 3.350,9.787 3.343 C 8.779 3.337,8.552 3.342,8.449 3.374 M12.272 10.696 C 12.772 10.800,13.200 11.228,13.304 11.728 C 13.396 12.172,13.263 12.609,12.936 12.935 C 12.390 13.480,11.550 13.464,11.025 12.900 C 10.534 12.371,10.551 11.577,11.064 11.065 C 11.393 10.737,11.829 10.604,12.272 10.696 " ></path></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |