Merge branch 'fix-handleCallMemberEvent' into thirdroom/dev

This commit is contained in:
Robert Long 2022-05-10 17:01:11 -07:00
commit c6d1cba81c
21 changed files with 331 additions and 962 deletions

View file

@ -26,6 +26,7 @@
}, },
"homepage": "https://github.com/vector-im/hydrogen-web/#readme", "homepage": "https://github.com/vector-im/hydrogen-web/#readme",
"devDependencies": { "devDependencies": {
"@matrixdotorg/structured-logviewer": "^0.0.1",
"@typescript-eslint/eslint-plugin": "^4.29.2", "@typescript-eslint/eslint-plugin": "^4.29.2",
"@typescript-eslint/parser": "^4.29.2", "@typescript-eslint/parser": "^4.29.2",
"acorn": "^8.6.0", "acorn": "^8.6.0",

View file

@ -1,51 +0,0 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export function openFile(mimeType = null) {
const input = document.createElement("input");
input.setAttribute("type", "file");
input.className = "hidden";
if (mimeType) {
input.setAttribute("accept", mimeType);
}
const promise = new Promise((resolve, reject) => {
const checkFile = () => {
input.removeEventListener("change", checkFile, true);
const file = input.files[0];
document.body.removeChild(input);
if (file) {
resolve(file);
} else {
reject(new Error("no file picked"));
}
}
input.addEventListener("change", checkFile, true);
});
// IE11 needs the input to be attached to the document
document.body.appendChild(input);
input.click();
return promise;
}
export function readFileAsText(file) {
const reader = new FileReader();
const promise = new Promise((resolve, reject) => {
reader.addEventListener("load", evt => resolve(evt.target.result));
reader.addEventListener("error", evt => reject(evt.target.error));
});
reader.readAsText(file);
return promise;
}

View file

@ -1,110 +0,0 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// DOM helper functions
export function isChildren(children) {
// children should be an not-object (that's the attributes), or a domnode, or an array
return typeof children !== "object" || !!children.nodeType || Array.isArray(children);
}
export function classNames(obj, value) {
return Object.entries(obj).reduce((cn, [name, enabled]) => {
if (typeof enabled === "function") {
enabled = enabled(value);
}
if (enabled) {
return cn + (cn.length ? " " : "") + name;
} else {
return cn;
}
}, "");
}
export function setAttribute(el, name, value) {
if (name === "className") {
name = "class";
}
if (value === false) {
el.removeAttribute(name);
} else {
if (value === true) {
value = name;
}
el.setAttribute(name, value);
}
}
export function el(elementName, attributes, children) {
return elNS(HTML_NS, elementName, attributes, children);
}
export function elNS(ns, elementName, attributes, children) {
if (attributes && isChildren(attributes)) {
children = attributes;
attributes = null;
}
const e = document.createElementNS(ns, elementName);
if (attributes) {
for (let [name, value] of Object.entries(attributes)) {
if (name === "className" && typeof value === "object" && value !== null) {
value = classNames(value);
}
setAttribute(e, name, value);
}
}
if (children) {
if (!Array.isArray(children)) {
children = [children];
}
for (let c of children) {
if (!c.nodeType) {
c = text(c);
}
e.appendChild(c);
}
}
return e;
}
export function text(str) {
return document.createTextNode(str);
}
export const HTML_NS = "http://www.w3.org/1999/xhtml";
export const SVG_NS = "http://www.w3.org/2000/svg";
export const TAG_NAMES = {
[HTML_NS]: [
"br", "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6",
"p", "strong", "em", "span", "img", "section", "main", "article", "aside",
"pre", "button", "time", "input", "textarea", "label", "form", "progress", "output"],
[SVG_NS]: ["svg", "circle"]
};
export const tag = {};
for (const [ns, tags] of Object.entries(TAG_NAMES)) {
for (const tagName of tags) {
tag[tagName] = function(attributes, children) {
return elNS(ns, tagName, attributes, children);
}
}
}

View file

@ -1,222 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style type="text/css">
html, body {
height: 100%;
}
body {
font-family: sans-serif;
font-size: 1rem;
margin: 0;
display: grid;
grid-template-areas: "nav nav" "items details";
grid-template-columns: 1fr 400px;
grid-template-rows: auto 1fr;
min-height: 0;
}
main {
grid-area: items;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: 8px;
}
main section h2 {
margin: 2px 14px;
font-size: 1rem;
}
aside {
grid-area: details;
padding: 8px;
}
aside h3 {
word-wrap: anywhere;
}
aside p {
margin: 2px 0;
}
aside .values li span {
word-wrap: ;
word-wrap: anywhere;
padding: 4px;
}
aside .values {
list-style: none;
padding: 0;
border: 1px solid lightgray;
}
aside .values span.key {
width: 30%;
display: block;
}
aside .values span.value {
width: 70%;
display: block;
white-space: pre-wrap;
}
aside .values li {
display: flex;
}
aside .values li:not(:first-child) {
border-top: 1px solid lightgray;
}
nav {
grid-area: nav;
}
.timeline li:not(.expanded) > ol {
display: none;
}
.timeline li > div {
display: flex;
}
.timeline .toggleExpanded {
border: none;
background: none;
width: 24px;
height: 24px;
margin-right: 4px;
cursor: pointer;
}
.timeline .toggleExpanded:before {
content: "▶";
}
.timeline li.expanded > div > .toggleExpanded:before {
content: "▼";
}
.timeline ol {
list-style: none;
padding: 0 0 0 20px;
margin: 0;
}
.timeline .item {
--hue: 100deg;
--brightness: 80%;
background-color: hsl(var(--hue), 60%, var(--brightness));
border: 1px solid hsl(var(--hue), 60%, calc(var(--brightness) - 40%));
border-radius: 4px;
padding: 2px;
display: flex;
margin: 1px;
flex: 1;
min-width: 0;
color: inherit;
text-decoration: none;
}
.timeline .item:not(.has-children) {
margin-left: calc(24px + 4px + 1px);
}
.timeline .item .caption {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 1;
}
.timeline .item.level-3 {
--brightness: 90%;
}
.timeline .item.level-2 {
--brightness: 95%;
}
.timeline .item.level-5 {
--brightness: 80%;
}
.timeline .item.level-6, .timeline .item.level-7 {
--hue: 0deg !important;
}
.timeline .item.level-7 {
--brightness: 50%;
color: white;
}
.timeline .item.type-network {
--hue: 30deg;
}
.timeline .item.type-call {
--hue: 50deg;
}
.timeline .item.type-navigation {
--hue: 200deg;
}
.timeline .item.selected {
background-color: Highlight;
border-color: Highlight;
color: HighlightText;
}
.timeline .item.highlighted {
background-color: fuchsia;
color: white;
}
.hidden {
display: none;
}
#highlight {
width: 300px;
}
nav form {
display: inline;
}
#filename {
margin: 0;
font-size: 1rem;
padding: 4px 0;
}
</style>
</head>
<body>
<nav>
<div>
<button id="openFile">Open log file</button>
<button id="collapseAll">Collapse all</button>
<button id="hideCollapsed">Hide collapsed root items</button>
<button id="hideHighlightedSiblings" title="Hide collapsed siblings of highlighted">Hide non-highlighted</button>
<button id="showAll">Show all</button>
<form id="highlightForm">
<input type="text" id="highlight" name="highlight" placeholder="Highlight a search term" autocomplete="on">
<output id="highlightMatches"></output>
</form>
</div>
<h2 id="filename"></h2>
</nav>
<main></main>
<aside></aside>
<script type="module" src="main.js"></script>
</body>
</html>

View file

@ -1,425 +0,0 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {tag as t} from "./html.js";
import {openFile, readFileAsText} from "./file.js";
const main = document.querySelector("main");
let selectedItemNode;
let rootItem;
let itemByRef;
let itemsRefFrom;
const logLevels = [undefined, "All", "Debug", "Detail", "Info", "Warn", "Error", "Fatal", "Off"];
main.addEventListener("click", event => {
if (event.target.classList.contains("toggleExpanded")) {
const li = event.target.parentElement.parentElement;
li.classList.toggle("expanded");
} else {
// allow clicking any links other than .item in the timeline, like refs
if (event.target.tagName === "A" && !event.target.classList.contains("item")) {
return;
}
const itemNode = event.target.closest(".item");
if (itemNode) {
// we don't want scroll to jump when clicking
// so prevent default behaviour, and select and push to history manually
event.preventDefault();
selectNode(itemNode);
history.pushState(null, null, `#${itemNode.id}`);
}
}
});
window.addEventListener("hashchange", () => {
const id = window.location.hash.substr(1);
const itemNode = document.getElementById(id);
if (itemNode && itemNode.closest("main")) {
ensureParentsExpanded(itemNode);
selectNode(itemNode);
itemNode.scrollIntoView({behavior: "smooth", block: "nearest"});
}
});
function selectNode(itemNode) {
if (selectedItemNode) {
selectedItemNode.classList.remove("selected");
}
selectedItemNode = itemNode;
selectedItemNode.classList.add("selected");
let item = rootItem;
let parent;
const indices = selectedItemNode.id.split("/").map(i => parseInt(i, 10));
for(const i of indices) {
parent = item;
item = itemChildren(item)[i];
}
showItemDetails(item, parent, selectedItemNode);
}
function ensureParentsExpanded(itemNode) {
let li = itemNode.parentElement.parentElement;
while (li.tagName === "LI") {
li.classList.add("expanded");
li = li.parentElement.parentElement;
}
}
function stringifyItemValue(value) {
if (typeof value === "object" && value !== null) {
return JSON.stringify(value, undefined, 2);
} else {
return value + "";
}
}
function showItemDetails(item, parent, itemNode) {
const parentOffset = itemStart(parent) ? `${itemStart(item) - itemStart(parent)}ms` : "none";
const expandButton = t.button("Expand recursively");
expandButton.addEventListener("click", () => expandResursively(itemNode.parentElement.parentElement));
const start = itemStart(item);
const aside = t.aside([
t.h3(itemCaption(item)),
t.p([t.strong("Log level: "), logLevels[itemLevel(item)]]),
t.p([t.strong("Error: "), itemError(item) ? `${itemError(item).name} ${itemError(item).stack}` : "none"]),
t.p([t.strong("Parent offset: "), parentOffset]),
t.p([t.strong("Start: "), new Date(start).toString(), ` (${start})`]),
t.p([t.strong("Duration: "), `${itemDuration(item)}ms`]),
t.p([t.strong("Child count: "), itemChildren(item) ? `${itemChildren(item).length}` : "none"]),
t.p([t.strong("Forced finish: "), (itemForcedFinish(item) || false) + ""]),
t.p(t.strong("Values:")),
t.ul({class: "values"}, Object.entries(itemValues(item)).map(([key, value]) => {
let valueNode;
if (key === "ref") {
const refItem = itemByRef.get(value);
if (refItem) {
valueNode = t.a({href: `#${refItem.id}`}, itemCaption(refItem));
} else {
valueNode = `unknown ref ${value}`;
}
} else if (key === "refId") {
const refSources = itemsRefFrom.get(value) ?? [];
valueNode = t.div([t.p([`${value}`, t.br(),`Found these references:`]),t.ul(refSources.map(item => {
return t.li(t.a({href: `#${item.id}`}, itemCaption(item)));
}))]);
} else {
valueNode = stringifyItemValue(value);
}
return t.li([
t.span({className: "key"}, normalizeValueKey(key)),
t.span({className: "value"}, valueNode)
]);
})),
t.p(expandButton)
]);
document.querySelector("aside").replaceWith(aside);
}
function expandResursively(li) {
li.classList.add("expanded");
const ol = li.querySelector("ol");
if (ol) {
const len = ol.children.length;
for (let i = 0; i < len; i += 1) {
expandResursively(ol.children[i]);
}
}
}
document.getElementById("openFile").addEventListener("click", loadFile);
function getRootItemHeader(prevItem, item) {
if (prevItem) {
const diff = itemStart(item) - itemEnd(prevItem);
if (diff >= 0) {
return `+ ${formatTime(diff)}`;
} else {
const overlap = -diff;
if (overlap >= itemDuration(item)) {
return `ran entirely in parallel with`;
} else {
return `ran ${formatTime(-diff)} in parallel with`;
}
}
} else {
return new Date(itemStart(item)).toString();
}
}
async function loadFile() {
const file = await openFile();
document.getElementById("filename").innerText = file.name;
const json = await readFileAsText(file);
const logs = JSON.parse(json);
logs.items.sort((a, b) => itemStart(a) - itemStart(b));
rootItem = {c: logs.items};
itemByRef = new Map();
itemsRefFrom = new Map();
preprocessRecursively(rootItem, null, itemByRef, itemsRefFrom, []);
const fragment = logs.items.reduce((fragment, item, i, items) => {
const prevItem = i === 0 ? null : items[i - 1];
fragment.appendChild(t.section([
t.h2(getRootItemHeader(prevItem, item)),
t.div({className: "timeline"}, t.ol(itemToNode(item, [i])))
]));
return fragment;
}, document.createDocumentFragment());
main.replaceChildren(fragment);
}
// TODO: make this use processRecursively
function preprocessRecursively(item, parentElement, refsMap, refsFromMap, path) {
item.s = (parentElement?.s || 0) + item.s;
if (itemRefSource(item)) {
refsMap.set(itemRefSource(item), item);
}
if (itemRef(item)) {
let refs = refsFromMap.get(itemRef(item));
if (!refs) {
refs = [];
refsFromMap.set(itemRef(item), refs);
}
refs.push(item);
}
if (itemChildren(item)) {
for (let i = 0; i < itemChildren(item).length; i += 1) {
// do it in advance for a child as we don't want to do it for the rootItem
const child = itemChildren(item)[i];
const childPath = path.concat(i);
child.id = childPath.join("/");
preprocessRecursively(child, item, refsMap, refsFromMap, childPath);
}
}
}
const MS_IN_SEC = 1000;
const MS_IN_MIN = MS_IN_SEC * 60;
const MS_IN_HOUR = MS_IN_MIN * 60;
const MS_IN_DAY = MS_IN_HOUR * 24;
function formatTime(ms) {
let str = "";
if (ms > MS_IN_DAY) {
const days = Math.floor(ms / MS_IN_DAY);
ms -= days * MS_IN_DAY;
str += `${days}d`;
}
if (ms > MS_IN_HOUR) {
const hours = Math.floor(ms / MS_IN_HOUR);
ms -= hours * MS_IN_HOUR;
str += `${hours}h`;
}
if (ms > MS_IN_MIN) {
const mins = Math.floor(ms / MS_IN_MIN);
ms -= mins * MS_IN_MIN;
str += `${mins}m`;
}
if (ms > MS_IN_SEC) {
const secs = ms / MS_IN_SEC;
str += `${secs.toFixed(2)}s`;
} else if (ms > 0 || !str.length) {
str += `${ms}ms`;
}
return str;
}
function itemChildren(item) { return item.c; }
function itemStart(item) { return item.s; }
function itemEnd(item) { return item.s + item.d; }
function itemDuration(item) { return item.d; }
function itemValues(item) { return item.v; }
function itemLevel(item) { return item.l; }
function itemLabel(item) { return item.v?.l; }
function itemType(item) { return item.v?.t; }
function itemError(item) { return item.e; }
function itemForcedFinish(item) { return item.f; }
function itemRef(item) { return item.v?.ref; }
function itemRefSource(item) { return item.v?.refId; }
function itemShortErrorMessage(item) {
if (itemError(item)) {
const e = itemError(item);
return e.name || e.stack.substr(0, e.stack.indexOf("\n"));
}
}
function itemCaption(item) {
if (itemLabel(item) && itemError(item)) {
return `${itemLabel(item)} (${itemShortErrorMessage(item)})`;
} if (itemType(item) === "network") {
return `${itemValues(item)?.method} ${itemValues(item)?.url}`;
} else if (itemLabel(item) && itemValues(item)?.id) {
return `${itemLabel(item)} ${itemValues(item).id}`;
} else if (itemLabel(item) && itemValues(item)?.status) {
return `${itemLabel(item)} (${itemValues(item).status})`;
} else if (itemLabel(item) && itemValues(item)?.type) {
return `${itemLabel(item)} (${itemValues(item)?.type})`;
} else if (itemRef(item)) {
const refItem = itemByRef.get(itemRef(item));
if (refItem) {
return `ref "${itemCaption(refItem)}"`
} else {
return `unknown ref ${itemRef(item)}`
}
} else {
return itemLabel(item) || itemType(item);
}
}
function normalizeValueKey(key) {
switch (key) {
case "t": return "type";
case "l": return "label";
default: return key;
}
}
// returns the node and the total range (recursively) occupied by the node
function itemToNode(item) {
const hasChildren = !!itemChildren(item)?.length;
const className = {
item: true,
"has-children": hasChildren,
error: itemError(item),
[`type-${itemType(item)}`]: !!itemType(item),
[`level-${itemLevel(item)}`]: true,
};
const id = item.id;
let captionNode;
if (itemRef(item)) {
const refItem = itemByRef.get(itemRef(item));
if (refItem) {
captionNode = ["ref ", t.a({href: `#${refItem.id}`}, itemCaption(refItem))];
}
}
if (!captionNode) {
captionNode = itemCaption(item);
}
const li = t.li([
t.div([
hasChildren ? t.button({className: "toggleExpanded"}) : "",
t.a({className, id, href: `#${id}`}, [
t.span({class: "caption"}, captionNode),
t.span({class: "duration"}, `(${formatTime(itemDuration(item))})`),
])
])
]);
if (itemChildren(item) && itemChildren(item).length) {
li.appendChild(t.ol(itemChildren(item).map(item => {
return itemToNode(item);
})));
}
return li;
}
const highlightForm = document.getElementById("highlightForm");
highlightForm.addEventListener("submit", evt => {
evt.preventDefault();
const matchesOutput = document.getElementById("highlightMatches");
const query = document.getElementById("highlight").value;
if (query) {
matchesOutput.innerText = "Searching…";
let matches = 0;
processRecursively(rootItem, item => {
let domNode = document.getElementById(item.id);
if (itemMatchesFilter(item, query)) {
matches += 1;
domNode.classList.add("highlighted");
domNode = domNode.parentElement;
while (domNode.nodeName !== "SECTION") {
if (domNode.nodeName === "LI") {
domNode.classList.add("expanded");
}
domNode = domNode.parentElement;
}
} else {
domNode.classList.remove("highlighted");
}
});
matchesOutput.innerText = `${matches} matches`;
} else {
for (const node of document.querySelectorAll(".highlighted")) {
node.classList.remove("highlighted");
}
matchesOutput.innerText = "";
}
});
function itemMatchesFilter(item, query) {
if (itemError(item)) {
if (valueMatchesQuery(itemError(item), query)) {
return true;
}
}
return valueMatchesQuery(itemValues(item), query);
}
function valueMatchesQuery(value, query) {
if (typeof value === "string") {
return value.includes(query);
} else if (typeof value === "object" && value !== null) {
for (const key in value) {
if (value.hasOwnProperty(key) && valueMatchesQuery(value[key], query)) {
return true;
}
}
} else if (typeof value === "number") {
return value.toString().includes(query);
}
return false;
}
function processRecursively(item, callback, parentItem) {
if (item.id) {
callback(item, parentItem);
}
if (itemChildren(item)) {
for (let i = 0; i < itemChildren(item).length; i += 1) {
// do it in advance for a child as we don't want to do it for the rootItem
const child = itemChildren(item)[i];
processRecursively(child, callback, item);
}
}
}
document.getElementById("collapseAll").addEventListener("click", () => {
for (const node of document.querySelectorAll(".expanded")) {
node.classList.remove("expanded");
}
});
document.getElementById("hideCollapsed").addEventListener("click", () => {
for (const node of document.querySelectorAll("section > div.timeline > ol > li:not(.expanded)")) {
node.closest("section").classList.add("hidden");
}
});
document.getElementById("hideHighlightedSiblings").addEventListener("click", () => {
for (const node of document.querySelectorAll(".highlighted")) {
const list = node.closest("ol");
const siblings = Array.from(list.querySelectorAll("li > div > a:not(.highlighted)")).map(n => n.closest("li"));
for (const sibling of siblings) {
if (!sibling.classList.contains("expanded")) {
sibling.classList.add("hidden");
}
}
}
});
document.getElementById("showAll").addEventListener("click", () => {
for (const node of document.querySelectorAll(".hidden")) {
node.classList.remove("hidden");
}
});

View file

@ -136,8 +136,14 @@ export class SettingsViewModel extends ViewModel {
} }
async exportLogs() { async exportLogs() {
const logExport = await this.logger.export(); const logs = await this.exportLogsBlob();
this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`); this.platform.saveFileAs(logs, `hydrogen-logs-${this.platform.clock.now()}.json`);
}
async exportLogsBlob() {
const persister = this.logger.reporters.find(r => typeof r.export === "function");
const logExport = await persister.export();
return logExport.asBlob();
} }
async togglePushNotifications() { async togglePushNotifications() {

View file

@ -14,6 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export {Logger} from "./logging/Logger";
export {IDBLogPersister} from "./logging/IDBLogPersister";
export {ConsoleReporter} from "./logging/ConsoleReporter";
export {Platform} from "./platform/web/Platform.js"; export {Platform} from "./platform/web/Platform.js";
export {Client, LoadStatus} from "./matrix/Client.js"; export {Client, LoadStatus} from "./matrix/Client.js";
export {RoomStatus} from "./matrix/room/common"; export {RoomStatus} from "./matrix/room/common";

View file

@ -13,22 +13,27 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {BaseLogger} from "./BaseLogger";
import {LogItem} from "./LogItem";
import type {ILogItem, LogItemValues, ILogExport} from "./types";
export class ConsoleLogger extends BaseLogger { import type {ILogger, ILogItem, LogItemValues, ILogReporter} from "./types";
_persistItem(item: LogItem): void { import type {LogItem} from "./LogItem";
printToConsole(item);
export class ConsoleReporter implements ILogReporter {
private logger?: ILogger;
reportItem(item: ILogItem): void {
printToConsole(item as LogItem);
} }
async export(): Promise<ILogExport | undefined> { setLogger(logger: ILogger) {
return undefined; this.logger = logger;
} }
printOpenItems(): void { printOpenItems(): void {
for (const item of this._openItems) { if (!this.logger) {
this._persistItem(item); return;
}
for (const item of this.logger.getOpenRootItems()) {
this.reportItem(item);
} }
} }
} }

View file

@ -22,36 +22,69 @@ import {
iterateCursor, iterateCursor,
fetchResults, fetchResults,
} from "../matrix/storage/idb/utils"; } from "../matrix/storage/idb/utils";
import {BaseLogger} from "./BaseLogger";
import type {Interval} from "../platform/web/dom/Clock"; import type {Interval} from "../platform/web/dom/Clock";
import type {Platform} from "../platform/web/Platform.js"; import type {Platform} from "../platform/web/Platform.js";
import type {BlobHandle} from "../platform/web/dom/BlobHandle.js"; import type {BlobHandle} from "../platform/web/dom/BlobHandle.js";
import type {ILogItem, ILogExport, ISerializedItem} from "./types"; import type {ILogItem, ILogger, ILogReporter, ISerializedItem} from "./types";
import type {LogFilter} from "./LogFilter"; import {LogFilter} from "./LogFilter";
type QueuedItem = { type QueuedItem = {
json: string; json: string;
id?: number; id?: number;
} }
export class IDBLogger extends BaseLogger { type Options = {
private readonly _name: string; name: string,
private readonly _limit: number; flushInterval?: number,
limit?: number,
platform: Platform,
serializedTransformer?: (item: ISerializedItem) => ISerializedItem
}
export class IDBLogPersister implements ILogReporter {
private readonly _flushInterval: Interval; private readonly _flushInterval: Interval;
private _queuedItems: QueuedItem[]; private _queuedItems: QueuedItem[];
private readonly options: Options;
private logger?: ILogger;
constructor(options: {name: string, flushInterval?: number, limit?: number, platform: Platform, serializedTransformer?: (item: ISerializedItem) => ISerializedItem}) { constructor(options: Options) {
super(options); this.options = options;
const {name, flushInterval = 60 * 1000, limit = 3000} = options;
this._name = name;
this._limit = limit;
this._queuedItems = this._loadQueuedItems(); this._queuedItems = this._loadQueuedItems();
// TODO: also listen for unload just in case sync keeps on running after pagehide is fired? // TODO: also listen for unload just in case sync keeps on running after pagehide is fired?
window.addEventListener("pagehide", this, false); window.addEventListener("pagehide", this, false);
this._flushInterval = this._platform.clock.createInterval(() => this._tryFlush(), flushInterval); this._flushInterval = this.options.platform.clock.createInterval(
() => this._tryFlush(),
this.options.flushInterval ?? 60 * 1000
);
}
setLogger(logger: ILogger): void {
this.logger = logger;
}
reportItem(logItem: ILogItem, filter: LogFilter, forced: boolean): void {
const queuedItem = this.prepareItemForQueue(logItem, filter, forced);
if (queuedItem) {
this._queuedItems.push(queuedItem);
}
}
async export(): Promise<IDBLogExport> {
const db = await this._openDB();
try {
const txn = db.transaction(["logs"], "readonly");
const logs = txn.objectStore("logs");
const storedItems: QueuedItem[] = await fetchResults(logs.openCursor(), () => false);
const openItems = this.getSerializedOpenItems();
const allItems = storedItems.concat(this._queuedItems).concat(openItems);
return new IDBLogExport(allItems, this, this.options.platform);
} finally {
try {
db.close();
} catch (e) {}
}
} }
// TODO: move dispose to ILogger, listen to pagehide elsewhere and call dispose from there, which calls _finishAllAndFlush
dispose(): void { dispose(): void {
window.removeEventListener("pagehide", this, false); window.removeEventListener("pagehide", this, false);
this._flushInterval.dispose(); this._flushInterval.dispose();
@ -63,7 +96,7 @@ export class IDBLogger extends BaseLogger {
} }
} }
async _tryFlush(): Promise<void> { private async _tryFlush(): Promise<void> {
const db = await this._openDB(); const db = await this._openDB();
try { try {
const txn = db.transaction(["logs"], "readwrite"); const txn = db.transaction(["logs"], "readwrite");
@ -73,9 +106,10 @@ export class IDBLogger extends BaseLogger {
logs.add(i); logs.add(i);
} }
const itemCount = await reqAsPromise(logs.count()); const itemCount = await reqAsPromise(logs.count());
if (itemCount > this._limit) { const limit = this.options.limit ?? 3000;
if (itemCount > limit) {
// delete an extra 10% so we don't need to delete every time we flush // delete an extra 10% so we don't need to delete every time we flush
let deleteAmount = (itemCount - this._limit) + Math.round(0.1 * this._limit); let deleteAmount = (itemCount - limit) + Math.round(0.1 * limit);
await iterateCursor(logs.openCursor(), (_, __, cursor) => { await iterateCursor(logs.openCursor(), (_, __, cursor) => {
cursor.delete(); cursor.delete();
deleteAmount -= 1; deleteAmount -= 1;
@ -93,14 +127,16 @@ export class IDBLogger extends BaseLogger {
} }
} }
_finishAllAndFlush(): void { private _finishAllAndFlush(): void {
this._finishOpenItems(); if (this.logger) {
this.log({l: "pagehide, closing logs", t: "navigation"}); this.logger.log({l: "pagehide, closing logs", t: "navigation"});
this.logger.forceFinish();
}
this._persistQueuedItems(this._queuedItems); this._persistQueuedItems(this._queuedItems);
} }
_loadQueuedItems(): QueuedItem[] { private _loadQueuedItems(): QueuedItem[] {
const key = `${this._name}_queuedItems`; const key = `${this.options.name}_queuedItems`;
try { try {
const json = window.localStorage.getItem(key); const json = window.localStorage.getItem(key);
if (json) { if (json) {
@ -113,44 +149,32 @@ export class IDBLogger extends BaseLogger {
return []; return [];
} }
_openDB(): Promise<IDBDatabase> { private _openDB(): Promise<IDBDatabase> {
return openDatabase(this._name, db => db.createObjectStore("logs", {keyPath: "id", autoIncrement: true}), 1); return openDatabase(this.options.name, db => db.createObjectStore("logs", {keyPath: "id", autoIncrement: true}), 1);
} }
_persistItem(logItem: ILogItem, filter: LogFilter, forced: boolean): void { private prepareItemForQueue(logItem: ILogItem, filter: LogFilter, forced: boolean): QueuedItem | undefined {
const serializedItem = logItem.serialize(filter, undefined, forced); let serializedItem = logItem.serialize(filter, undefined, forced);
if (serializedItem) { if (serializedItem) {
const transformedSerializedItem = this._serializedTransformer(serializedItem); if (this.options.serializedTransformer) {
this._queuedItems.push({ serializedItem = this.options.serializedTransformer(serializedItem);
json: JSON.stringify(transformedSerializedItem) }
}); return {
json: JSON.stringify(serializedItem)
};
} }
} }
_persistQueuedItems(items: QueuedItem[]): void { private _persistQueuedItems(items: QueuedItem[]): void {
try { try {
window.localStorage.setItem(`${this._name}_queuedItems`, JSON.stringify(items)); window.localStorage.setItem(`${this.options.name}_queuedItems`, JSON.stringify(items));
} catch (e) { } catch (e) {
console.error("Could not persist queued log items in localStorage, they will likely be lost", e); console.error("Could not persist queued log items in localStorage, they will likely be lost", e);
} }
} }
async export(): Promise<ILogExport> { /** @internal called by ILogExport.removeFromStore */
const db = await this._openDB(); async removeItems(items: QueuedItem[]): Promise<void> {
try {
const txn = db.transaction(["logs"], "readonly");
const logs = txn.objectStore("logs");
const storedItems: QueuedItem[] = await fetchResults(logs.openCursor(), () => false);
const allItems = storedItems.concat(this._queuedItems);
return new IDBLogExport(allItems, this, this._platform);
} finally {
try {
db.close();
} catch (e) {}
}
}
async _removeItems(items: QueuedItem[]): Promise<void> {
const db = await this._openDB(); const db = await this._openDB();
try { try {
const txn = db.transaction(["logs"], "readwrite"); const txn = db.transaction(["logs"], "readwrite");
@ -173,14 +197,29 @@ export class IDBLogger extends BaseLogger {
} catch (e) {} } catch (e) {}
} }
} }
private getSerializedOpenItems(): QueuedItem[] {
const openItems: QueuedItem[] = [];
if (!this.logger) {
return openItems;
}
const filter = new LogFilter();
for(const item of this.logger!.getOpenRootItems()) {
const openItem = this.prepareItemForQueue(item, filter, false);
if (openItem) {
openItems.push(openItem);
}
}
return openItems;
}
} }
class IDBLogExport implements ILogExport { export class IDBLogExport {
private readonly _items: QueuedItem[]; private readonly _items: QueuedItem[];
private readonly _logger: IDBLogger; private readonly _logger: IDBLogPersister;
private readonly _platform: Platform; private readonly _platform: Platform;
constructor(items: QueuedItem[], logger: IDBLogger, platform: Platform) { constructor(items: QueuedItem[], logger: IDBLogPersister, platform: Platform) {
this._items = items; this._items = items;
this._logger = logger; this._logger = logger;
this._platform = platform; this._platform = platform;
@ -194,18 +233,23 @@ class IDBLogExport implements ILogExport {
* @return {Promise} * @return {Promise}
*/ */
removeFromStore(): Promise<void> { removeFromStore(): Promise<void> {
return this._logger._removeItems(this._items); return this._logger.removeItems(this._items);
} }
asBlob(): BlobHandle { asBlob(): BlobHandle {
const json = this.toJSON();
const buffer: Uint8Array = this._platform.encoding.utf8.encode(json);
const blob: BlobHandle = this._platform.createBlob(buffer, "application/json");
return blob;
}
toJSON(): string {
const log = { const log = {
formatVersion: 1, formatVersion: 1,
appVersion: this._platform.updateService?.version, appVersion: this._platform.updateService?.version,
items: this._items.map(i => JSON.parse(i.json)) items: this._items.map(i => JSON.parse(i.json))
}; };
const json = JSON.stringify(log); const json = JSON.stringify(log);
const buffer: Uint8Array = this._platform.encoding.utf8.encode(json); return json;
const blob: BlobHandle = this._platform.createBlob(buffer, "application/json");
return blob;
} }
} }

View file

@ -16,7 +16,7 @@ limitations under the License.
*/ */
import {LogLevel, LogFilter} from "./LogFilter"; import {LogLevel, LogFilter} from "./LogFilter";
import type {BaseLogger} from "./BaseLogger"; import type {Logger} from "./Logger";
import type {ISerializedItem, ILogItem, LogItemValues, LabelOrValues, FilterCreator, LogCallback} from "./types"; import type {ISerializedItem, ILogItem, LogItemValues, LabelOrValues, FilterCreator, LogCallback} from "./types";
export class LogItem implements ILogItem { export class LogItem implements ILogItem {
@ -25,11 +25,11 @@ export class LogItem implements ILogItem {
public error?: Error; public error?: Error;
public end?: number; public end?: number;
private _values: LogItemValues; private _values: LogItemValues;
protected _logger: BaseLogger; protected _logger: Logger;
private _filterCreator?: FilterCreator; private _filterCreator?: FilterCreator;
private _children?: Array<LogItem>; private _children?: Array<LogItem>;
constructor(labelOrValues: LabelOrValues, logLevel: LogLevel, logger: BaseLogger, filterCreator?: FilterCreator) { constructor(labelOrValues: LabelOrValues, logLevel: LogLevel, logger: Logger, filterCreator?: FilterCreator) {
this._logger = logger; this._logger = logger;
this.start = logger._now(); this.start = logger._now();
// (l)abel // (l)abel
@ -38,7 +38,7 @@ export class LogItem implements ILogItem {
this._filterCreator = filterCreator; this._filterCreator = filterCreator;
} }
/** start a new root log item and run it detached mode, see BaseLogger.runDetached */ /** start a new root log item and run it detached mode, see Logger.runDetached */
runDetached(labelOrValues: LabelOrValues, callback: LogCallback<unknown>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem { runDetached(labelOrValues: LabelOrValues, callback: LogCallback<unknown>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem {
return this._logger.runDetached(labelOrValues, callback, logLevel, filterCreator); return this._logger.runDetached(labelOrValues, callback, logLevel, filterCreator);
} }
@ -253,7 +253,7 @@ export class LogItem implements ILogItem {
return item; return item;
} }
get logger(): BaseLogger { get logger(): Logger {
return this._logger; return this._logger;
} }

View file

@ -17,17 +17,17 @@ limitations under the License.
import {LogItem} from "./LogItem"; import {LogItem} from "./LogItem";
import {LogLevel, LogFilter} from "./LogFilter"; import {LogLevel, LogFilter} from "./LogFilter";
import type {ILogger, ILogExport, FilterCreator, LabelOrValues, LogCallback, ILogItem, ISerializedItem} from "./types"; import type {ILogger, ILogReporter, FilterCreator, LabelOrValues, LogCallback, ILogItem, ISerializedItem} from "./types";
import type {Platform} from "../platform/web/Platform.js"; import type {Platform} from "../platform/web/Platform.js";
export abstract class BaseLogger implements ILogger { export class Logger implements ILogger {
protected _openItems: Set<LogItem> = new Set(); protected _openItems: Set<LogItem> = new Set();
protected _platform: Platform; protected _platform: Platform;
protected _serializedTransformer: (item: ISerializedItem) => ISerializedItem; protected _serializedTransformer: (item: ISerializedItem) => ISerializedItem;
public readonly reporters: ILogReporter[] = [];
constructor({platform, serializedTransformer = (item: ISerializedItem) => item}) { constructor({platform}) {
this._platform = platform; this._platform = platform;
this._serializedTransformer = serializedTransformer;
} }
log(labelOrValues: LabelOrValues, logLevel: LogLevel = LogLevel.Info): void { log(labelOrValues: LabelOrValues, logLevel: LogLevel = LogLevel.Info): void {
@ -79,10 +79,10 @@ export abstract class BaseLogger implements ILogger {
return this._run(item, callback, logLevel, true, filterCreator); return this._run(item, callback, logLevel, true, filterCreator);
} }
_run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: true, filterCreator?: FilterCreator): T; private _run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: true, filterCreator?: FilterCreator): T;
// we don't return if we don't throw, as we don't have anything to return when an error is caught but swallowed for the fire-and-forget case. // we don't return if we don't throw, as we don't have anything to return when an error is caught but swallowed for the fire-and-forget case.
_run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: false, filterCreator?: FilterCreator): void; private _run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: false, filterCreator?: FilterCreator): void;
_run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: boolean, filterCreator?: FilterCreator): T | void { private _run<T>(item: LogItem, callback: LogCallback<T>, logLevel: LogLevel, wantResult: boolean, filterCreator?: FilterCreator): T | void {
this._openItems.add(item); this._openItems.add(item);
const finishItem = () => { const finishItem = () => {
@ -134,7 +134,16 @@ export abstract class BaseLogger implements ILogger {
} }
} }
_finishOpenItems() { addReporter(reporter: ILogReporter): void {
reporter.setLogger(this);
this.reporters.push(reporter);
}
getOpenRootItems(): Iterable<ILogItem> {
return this._openItems;
}
forceFinish() {
for (const openItem of this._openItems) { for (const openItem of this._openItems) {
openItem.forceFinish(); openItem.forceFinish();
try { try {
@ -150,19 +159,24 @@ export abstract class BaseLogger implements ILogger {
this._openItems.clear(); this._openItems.clear();
} }
abstract _persistItem(item: LogItem, filter?: LogFilter, forced?: boolean): void; /** @internal */
_persistItem(item: LogItem, filter?: LogFilter, forced?: boolean): void {
abstract export(): Promise<ILogExport | undefined>; for (var i = 0; i < this.reporters.length; i += 1) {
this.reporters[i].reportItem(item, filter, forced);
}
}
// expose log level without needing // expose log level without needing
get level(): typeof LogLevel { get level(): typeof LogLevel {
return LogLevel; return LogLevel;
} }
/** @internal */
_now(): number { _now(): number {
return this._platform.clock.now(); return this._platform.clock.now();
} }
/** @internal */
_createRefId(): number { _createRefId(): number {
return Math.round(this._platform.random() * Number.MAX_SAFE_INTEGER); return Math.round(this._platform.random() * Number.MAX_SAFE_INTEGER);
} }
@ -171,7 +185,7 @@ export abstract class BaseLogger implements ILogger {
class DeferredPersistRootLogItem extends LogItem { class DeferredPersistRootLogItem extends LogItem {
finish() { finish() {
super.finish(); super.finish();
(this._logger as BaseLogger)._persistItem(this, undefined, false); (this._logger as Logger)._persistItem(this, undefined, false);
} }
forceFinish() { forceFinish() {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {LogLevel} from "./LogFilter"; import {LogLevel} from "./LogFilter";
import type {ILogger, ILogExport, ILogItem, LabelOrValues, LogCallback, LogItemValues} from "./types"; import type {ILogger, ILogItem, ILogReporter, LabelOrValues, LogCallback, LogItemValues} from "./types";
function noop (): void {} function noop (): void {}
@ -23,6 +23,18 @@ export class NullLogger implements ILogger {
log(): void {} log(): void {}
addReporter() {}
get reporters(): ReadonlyArray<ILogReporter> {
return [];
}
getOpenRootItems(): Iterable<ILogItem> {
return [];
}
forceFinish(): void {}
child(): ILogItem { child(): ILogItem {
return this.item; return this.item;
} }
@ -44,23 +56,19 @@ export class NullLogger implements ILogger {
return this.item; return this.item;
} }
async export(): Promise<ILogExport | undefined> {
return undefined;
}
get level(): typeof LogLevel { get level(): typeof LogLevel {
return LogLevel; return LogLevel;
} }
} }
export class NullLogItem implements ILogItem { export class NullLogItem implements ILogItem {
public readonly logger: ILogger; public readonly logger: NullLogger;
public readonly logLevel: LogLevel; public readonly logLevel: LogLevel;
public children?: Array<ILogItem>; public children?: Array<ILogItem>;
public values: LogItemValues; public values: LogItemValues;
public error?: Error; public error?: Error;
constructor(logger: ILogger) { constructor(logger: NullLogger) {
this.logger = logger; this.logger = logger;
} }

View file

@ -16,7 +16,6 @@ limitations under the License.
*/ */
import {LogLevel, LogFilter} from "./LogFilter"; import {LogLevel, LogFilter} from "./LogFilter";
import type {BaseLogger} from "./BaseLogger";
import type {BlobHandle} from "../platform/web/dom/BlobHandle.js"; import type {BlobHandle} from "../platform/web/dom/BlobHandle.js";
export interface ISerializedItem { export interface ISerializedItem {
@ -40,7 +39,7 @@ export interface ILogItem {
readonly level: typeof LogLevel; readonly level: typeof LogLevel;
readonly end?: number; readonly end?: number;
readonly start?: number; readonly start?: number;
readonly values: LogItemValues; readonly values: Readonly<LogItemValues>;
wrap<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T; wrap<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
/*** This is sort of low-level, you probably want to use wrap. If you do use it, it should only be called once. */ /*** This is sort of low-level, you probably want to use wrap. If you do use it, it should only be called once. */
run<T>(callback: LogCallback<T>): T; run<T>(callback: LogCallback<T>): T;
@ -74,14 +73,20 @@ export interface ILogger {
wrapOrRun<T>(item: ILogItem | undefined, labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T; wrapOrRun<T>(item: ILogItem | undefined, labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
runDetached<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem; runDetached<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
run<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T; run<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
export(): Promise<ILogExport | undefined>;
get level(): typeof LogLevel; get level(): typeof LogLevel;
getOpenRootItems(): Iterable<ILogItem>;
addReporter(reporter: ILogReporter): void;
get reporters(): ReadonlyArray<ILogReporter>;
/**
* force-finishes any open items and passes them to the reporter, with the forced flag set.
* Good think to do when the page is being closed to not lose any logs.
**/
forceFinish(): void;
} }
export interface ILogExport { export interface ILogReporter {
get count(): number; setLogger(logger: ILogger): void;
removeFromStore(): Promise<void>; reportItem(item: ILogItem, filter?: LogFilter, forced?: boolean): void;
asBlob(): BlobHandle;
} }
export type LogItemValues = { export type LogItemValues = {

View file

@ -40,11 +40,15 @@ export type Options = Omit<GroupCallOptions, "emitUpdate" | "createTimeout"> & {
clock: Clock clock: Clock
}; };
function getRoomMemberKey(roomId: string, userId: string) {
return JSON.stringify(roomId)+`,`+JSON.stringify(userId);
}
export class CallHandler { export class CallHandler {
// group calls by call id // group calls by call id
private readonly _calls: ObservableMap<string, GroupCall> = new ObservableMap<string, GroupCall>(); private readonly _calls: ObservableMap<string, GroupCall> = new ObservableMap<string, GroupCall>();
// map of userId to set of conf_id's they are in // map of `"roomId","userId"` to set of conf_id's they are in
private memberToCallIds: Map<string, Set<string>> = new Map(); private roomMemberToCallIds: Map<string, Set<string>> = new Map();
private groupCallOptions: GroupCallOptions; private groupCallOptions: GroupCallOptions;
private sessionId = makeId("s"); private sessionId = makeId("s");
@ -98,7 +102,7 @@ export class CallHandler {
// } // }
const callsMemberEvents = await txn.roomState.getAllForType(roomId, EventType.GroupCallMember); const callsMemberEvents = await txn.roomState.getAllForType(roomId, EventType.GroupCallMember);
for (const entry of callsMemberEvents) { for (const entry of callsMemberEvents) {
this.handleCallMemberEvent(entry.event, log); this.handleCallMemberEvent(entry.event, roomId, log);
} }
// TODO: we should be loading the other members as well at some point // TODO: we should be loading the other members as well at some point
})); }));
@ -149,7 +153,7 @@ export class CallHandler {
// then update members // then update members
for (const event of events) { for (const event of events) {
if (event.type === EventType.GroupCallMember) { if (event.type === EventType.GroupCallMember) {
this.handleCallMemberEvent(event, log); this.handleCallMemberEvent(event, room.id, log);
} }
} }
} }
@ -194,8 +198,9 @@ export class CallHandler {
} }
} }
private handleCallMemberEvent(event: StateEvent, log: ILogItem) { private handleCallMemberEvent(event: StateEvent, roomId: string, log: ILogItem) {
const userId = event.state_key; const userId = event.state_key;
const roomMemberKey = getRoomMemberKey(roomId, userId)
const calls = event.content["m.calls"] ?? []; const calls = event.content["m.calls"] ?? [];
const eventTimestamp = event.origin_server_ts; const eventTimestamp = event.origin_server_ts;
for (const call of calls) { for (const call of calls) {
@ -205,7 +210,8 @@ export class CallHandler {
groupCall?.updateMembership(userId, call, eventTimestamp, log); groupCall?.updateMembership(userId, call, eventTimestamp, log);
}; };
const newCallIdsMemberOf = new Set<string>(calls.map(call => call["m.call_id"])); const newCallIdsMemberOf = new Set<string>(calls.map(call => call["m.call_id"]));
let previousCallIdsMemberOf = this.memberToCallIds.get(userId); let previousCallIdsMemberOf = this.roomMemberToCallIds.get(roomMemberKey);
// remove user as member of any calls not present anymore // remove user as member of any calls not present anymore
if (previousCallIdsMemberOf) { if (previousCallIdsMemberOf) {
for (const previousCallId of previousCallIdsMemberOf) { for (const previousCallId of previousCallIdsMemberOf) {
@ -216,9 +222,9 @@ export class CallHandler {
} }
} }
if (newCallIdsMemberOf.size === 0) { if (newCallIdsMemberOf.size === 0) {
this.memberToCallIds.delete(userId); this.roomMemberToCallIds.delete(roomMemberKey);
} else { } else {
this.memberToCallIds.set(userId, newCallIdsMemberOf); this.roomMemberToCallIds.set(roomMemberKey, newCallIdsMemberOf);
} }
} }
} }

View file

@ -15,7 +15,7 @@
- DONE: implement renegotiation - DONE: implement renegotiation
- DONE: finish session id support - DONE: finish session id support
- call peers are essentially identified by (userid, deviceid, sessionid). If see a new session id, we first disconnect from the current member so we're ready to connect with a clean slate again (in a member event, also in to_device? no harm I suppose, given olm encryption ensures you can't spoof the deviceid). - call peers are essentially identified by (userid, deviceid, sessionid). If see a new session id, we first disconnect from the current member so we're ready to connect with a clean slate again (in a member event, also in to_device? no harm I suppose, given olm encryption ensures you can't spoof the deviceid).
- making logging better - DONE: making logging better
- figure out why sometimes leave button does not work - figure out why sometimes leave button does not work
- get correct members and avatars in call - get correct members and avatars in call
- improve UI while in a call - improve UI while in a call

View file

@ -129,6 +129,14 @@ export class GroupCall extends EventEmitter<{change: never}> {
return this._eventTimestamp; return this._eventTimestamp;
} }
/**
* Gives access the log item for this call while joined.
* Can be used for call diagnostics while in the call.
**/
get logItem(): ILogItem | undefined {
return this.joinedData?.logItem;
}
async join(localMedia: LocalMedia): Promise<void> { async join(localMedia: LocalMedia): Promise<void> {
if (this._state !== GroupCallState.Created || this.joinedData) { if (this._state !== GroupCallState.Created || this.joinedData) {
return; return;
@ -250,8 +258,7 @@ export class GroupCall extends EventEmitter<{change: never}> {
/** @internal */ /** @internal */
updateCallEvent(callContent: Record<string, any>, syncLog: ILogItem) { updateCallEvent(callContent: Record<string, any>, syncLog: ILogItem) {
syncLog.wrap({l: "update call", t: CALL_LOG_TYPE}, log => { syncLog.wrap({l: "update call", t: CALL_LOG_TYPE, id: this.id}, log => {
log.set("id", this.id);
this.callContent = callContent; this.callContent = callContent;
if (this._state === GroupCallState.Creating) { if (this._state === GroupCallState.Creating) {
this._state = GroupCallState.Created; this._state = GroupCallState.Created;
@ -290,7 +297,10 @@ export class GroupCall extends EventEmitter<{change: never}> {
} else { } else {
if (member && sessionIdChanged) { if (member && sessionIdChanged) {
log.set("removedSessionId", member.sessionId); log.set("removedSessionId", member.sessionId);
member.disconnect(false, log); const disconnectLogItem = member.disconnect(false);
if (disconnectLogItem) {
log.refDetached(disconnectLogItem);
}
this._members.remove(memberKey); this._members.remove(memberKey);
member = undefined; member = undefined;
} }
@ -315,9 +325,7 @@ export class GroupCall extends EventEmitter<{change: never}> {
// remove user as member of any calls not present anymore // remove user as member of any calls not present anymore
for (const previousDeviceId of previousDeviceIds) { for (const previousDeviceId of previousDeviceIds) {
if (!newDeviceIds.has(previousDeviceId)) { if (!newDeviceIds.has(previousDeviceId)) {
log.wrap({l: "remove device member", id: getMemberKey(userId, previousDeviceId)}, log => {
this.removeMemberDevice(userId, previousDeviceId, log); this.removeMemberDevice(userId, previousDeviceId, log);
});
} }
} }
if (userId === this.options.ownUserId && !newDeviceIds.has(this.options.ownDeviceId)) { if (userId === this.options.ownUserId && !newDeviceIds.has(this.options.ownDeviceId)) {
@ -332,7 +340,8 @@ export class GroupCall extends EventEmitter<{change: never}> {
syncLog.wrap({ syncLog.wrap({
l: "remove call member", l: "remove call member",
t: CALL_LOG_TYPE, t: CALL_LOG_TYPE,
id: this.id id: this.id,
userId
}, log => { }, log => {
for (const deviceId of deviceIds) { for (const deviceId of deviceIds) {
this.removeMemberDevice(userId, deviceId, log); this.removeMemberDevice(userId, deviceId, log);
@ -379,7 +388,10 @@ export class GroupCall extends EventEmitter<{change: never}> {
disconnect(log: ILogItem) { disconnect(log: ILogItem) {
if (this._state === GroupCallState.Joined) { if (this._state === GroupCallState.Joined) {
for (const [,member] of this._members) { for (const [,member] of this._members) {
member.disconnect(true, log); const disconnectLogItem = member.disconnect(true);
if (disconnectLogItem) {
log.refDetached(disconnectLogItem);
}
} }
this._state = GroupCallState.Created; this._state = GroupCallState.Created;
} }
@ -391,14 +403,18 @@ export class GroupCall extends EventEmitter<{change: never}> {
/** @internal */ /** @internal */
private removeMemberDevice(userId: string, deviceId: string, log: ILogItem) { private removeMemberDevice(userId: string, deviceId: string, log: ILogItem) {
const memberKey = getMemberKey(userId, deviceId); const memberKey = getMemberKey(userId, deviceId);
log.set("id", memberKey); log.wrap({l: "remove device member", id: memberKey}, log => {
const member = this._members.get(memberKey); const member = this._members.get(memberKey);
if (member) { if (member) {
log.set("leave", true); log.set("leave", true);
this._members.remove(memberKey); this._members.remove(memberKey);
member.disconnect(false, log); const disconnectLogItem = member.disconnect(false);
if (disconnectLogItem) {
log.refDetached(disconnectLogItem);
}
} }
this.emitChange(); this.emitChange();
});
} }
/** @internal */ /** @internal */
@ -479,11 +495,16 @@ export class GroupCall extends EventEmitter<{change: never}> {
} }
private connectToMember(member: Member, joinedData: JoinedData, log: ILogItem) { private connectToMember(member: Member, joinedData: JoinedData, log: ILogItem) {
const logItem = joinedData.membersLogItem.child({l: "member", id: getMemberKey(member.userId, member.deviceId)}); const memberKey = getMemberKey(member.userId, member.deviceId);
const logItem = joinedData.membersLogItem.child({l: "member", id: memberKey});
logItem.set("sessionId", member.sessionId); logItem.set("sessionId", member.sessionId);
log.refDetached(logItem); log.wrap({l: "connect", id: memberKey}, log => {
// Safari can't send a MediaStream to multiple sources, so clone it // Safari can't send a MediaStream to multiple sources, so clone it
member.connect(joinedData.localMedia.clone(), joinedData.localMuteSettings, logItem); const connectItem = member.connect(joinedData.localMedia.clone(), joinedData.localMuteSettings, logItem);
if (connectItem) {
log.refDetached(connectItem);
}
})
} }
protected emitChange() { protected emitChange() {

View file

@ -72,6 +72,15 @@ export class Member {
private readonly options: Options, private readonly options: Options,
) {} ) {}
/**
* Gives access the log item for this item once joined to the group call.
* The signalling for this member will be log in this item.
* Can be used for call diagnostics while in the call.
**/
get logItem(): ILogItem | undefined {
return this.connection?.logItem;
}
get remoteMedia(): RemoteMedia | undefined { get remoteMedia(): RemoteMedia | undefined {
return this.connection?.peerCall?.remoteMedia; return this.connection?.peerCall?.remoteMedia;
} }
@ -110,15 +119,18 @@ export class Member {
} }
/** @internal */ /** @internal */
connect(localMedia: LocalMedia, localMuteSettings: MuteSettings, memberLogItem: ILogItem) { connect(localMedia: LocalMedia, localMuteSettings: MuteSettings, memberLogItem: ILogItem): ILogItem | undefined {
if (this.connection) { if (this.connection) {
return; return;
} }
const connection = new MemberConnection(localMedia, localMuteSettings, memberLogItem); const connection = new MemberConnection(localMedia, localMuteSettings, memberLogItem);
this.connection = connection; this.connection = connection;
let connectLogItem;
connection.logItem.wrap("connect", async log => { connection.logItem.wrap("connect", async log => {
connectLogItem = log;
await this.callIfNeeded(log); await this.callIfNeeded(log);
}); });
return connectLogItem;
} }
private callIfNeeded(log: ILogItem): Promise<void> { private callIfNeeded(log: ILogItem): Promise<void> {
@ -146,13 +158,14 @@ export class Member {
} }
/** @internal */ /** @internal */
disconnect(hangup: boolean, causeItem: ILogItem) { disconnect(hangup: boolean): ILogItem | undefined {
const {connection} = this; const {connection} = this;
if (!connection) { if (!connection) {
return; return;
} }
let disconnectLogItem;
connection.logItem.wrap("disconnect", async log => { connection.logItem.wrap("disconnect", async log => {
log.refDetached(causeItem); disconnectLogItem = log;
if (hangup) { if (hangup) {
connection.peerCall?.hangup(CallErrorCode.UserHangup, log); connection.peerCall?.hangup(CallErrorCode.UserHangup, log);
} else { } else {
@ -163,6 +176,7 @@ export class Member {
this.connection = undefined; this.connection = undefined;
}); });
connection.logItem.finish(); connection.logItem.finish();
return disconnectLogItem;
} }
/** @internal */ /** @internal */
@ -202,7 +216,10 @@ export class Member {
if (retryCount <= 3) { if (retryCount <= 3) {
await this.callIfNeeded(retryLog); await this.callIfNeeded(retryLog);
} else { } else {
this.disconnect(false, retryLog); const disconnectLogItem = this.disconnect(false);
if (disconnectLogItem) {
retryLog.refDetached(disconnectLogItem);
}
} }
}); });
} }

View file

@ -21,8 +21,9 @@ import {SessionInfoStorage} from "../../matrix/sessioninfo/localstorage/SessionI
import {SettingsStorage} from "./dom/SettingsStorage.js"; import {SettingsStorage} from "./dom/SettingsStorage.js";
import {Encoding} from "./utils/Encoding.js"; import {Encoding} from "./utils/Encoding.js";
import {OlmWorker} from "../../matrix/e2ee/OlmWorker.js"; import {OlmWorker} from "../../matrix/e2ee/OlmWorker.js";
import {IDBLogger} from "../../logging/IDBLogger"; import {IDBLogPersister} from "../../logging/IDBLogPersister";
import {ConsoleLogger} from "../../logging/ConsoleLogger"; import {ConsoleReporter} from "../../logging/ConsoleReporter";
import {Logger} from "../../logging/Logger";
import {RootView} from "./ui/RootView.js"; import {RootView} from "./ui/RootView.js";
import {Clock} from "./dom/Clock.js"; import {Clock} from "./dom/Clock.js";
import {ServiceWorkerHandler} from "./dom/ServiceWorkerHandler.js"; import {ServiceWorkerHandler} from "./dom/ServiceWorkerHandler.js";
@ -128,7 +129,7 @@ function adaptUIOnVisualViewportResize(container) {
} }
export class Platform { export class Platform {
constructor({ container, assetPaths, config, configURL, options = null, cryptoExtras = null }) { constructor({ container, assetPaths, config, configURL, logger, options = null, cryptoExtras = null }) {
this._container = container; this._container = container;
this._assetPaths = assetPaths; this._assetPaths = assetPaths;
this._config = config; this._config = config;
@ -137,7 +138,7 @@ export class Platform {
this.clock = new Clock(); this.clock = new Clock();
this.encoding = new Encoding(); this.encoding = new Encoding();
this.random = Math.random; this.random = Math.random;
this._createLogger(options?.development); this.logger = logger ?? this._createLogger(options?.development);
this.history = new History(); this.history = new History();
this.onlineStatus = new OnlineStatus(); this.onlineStatus = new OnlineStatus();
this._serviceWorkerHandler = null; this._serviceWorkerHandler = null;
@ -185,6 +186,7 @@ export class Platform {
} }
_createLogger(isDevelopment) { _createLogger(isDevelopment) {
const logger = new Logger({platform: this});
// Make sure that loginToken does not end up in the logs // Make sure that loginToken does not end up in the logs
const transformer = (item) => { const transformer = (item) => {
if (item.e?.stack) { if (item.e?.stack) {
@ -192,11 +194,12 @@ export class Platform {
} }
return item; return item;
}; };
const logPersister = new IDBLogPersister({name: "hydrogen_logs", platform: this, serializedTransformer: transformer});
logger.addReporter(logPersister);
if (isDevelopment) { if (isDevelopment) {
this.logger = new ConsoleLogger({platform: this}); logger.addReporter(new ConsoleReporter());
} else {
this.logger = new IDBLogger({name: "hydrogen_logs", platform: this, serializedTransformer: transformer});
} }
return logger;
} }
get updateService() { get updateService() {
@ -320,24 +323,22 @@ import {LogItem} from "../../logging/LogItem";
export function tests() { export function tests() {
return { return {
"loginToken should not be in logs": (assert) => { "loginToken should not be in logs": (assert) => {
const transformer = (item) => { const logPersister = Object.create(IDBLogPersister.prototype);
logPersister._queuedItems = [];
logPersister.options = {
serializedTransformer: (item) => {
if (item.e?.stack) { if (item.e?.stack) {
item.e.stack = item.e.stack.replace(/(?<=\/\?loginToken=).+/, "<snip>"); item.e.stack = item.e.stack.replace(/(?<=\/\?loginToken=).+/, "<snip>");
} }
return item; return item;
}
}; };
const logger = { const logger = { _now() {return 5;} };
_queuedItems: [],
_serializedTransformer: transformer,
_now: () => {}
};
logger.persist = IDBLogger.prototype._persistItem.bind(logger);
const logItem = new LogItem("test", 1, logger); const logItem = new LogItem("test", 1, logger);
logItem.error = new Error(); logItem.error = new Error();
logItem.error.stack = "main http://localhost:3000/src/main.js:55\n<anonymous> http://localhost:3000/?loginToken=secret:26" logItem.error.stack = "main http://localhost:3000/src/main.js:55\n<anonymous> http://localhost:3000/?loginToken=secret:26"
logger.persist(logItem, null, false); logPersister.reportItem(logItem, null, false);
const item = logger._queuedItems.pop(); const item = logPersister._queuedItems.pop();
console.log(item);
assert.strictEqual(item.json.search("secret"), -1); assert.strictEqual(item.json.search("secret"), -1);
} }
}; };

View file

@ -98,13 +98,18 @@ export class SettingsView extends TemplateView {
t.h3("Preferences"), t.h3("Preferences"),
row(t, vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)), row(t, vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)),
); );
const logButtons = [t.button({onClick: () => vm.exportLogs()}, "Export")];
if (import.meta.env.DEV) {
logButtons.push(t.button({onClick: () => openLogs(vm)}, "Open logs"));
}
settingNodes.push( settingNodes.push(
t.h3("Application"), t.h3("Application"),
row(t, vm.i18n`Version`, version), row(t, vm.i18n`Version`, version),
row(t, vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`), row(t, vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`),
row(t, vm.i18n`Debug logs`, t.button({onClick: () => vm.exportLogs()}, "Export")), row(t, vm.i18n`Debug logs`, logButtons),
t.p(["Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited, the usernames of other users and the names of files you send. They do not contain messages. For more information, review our ", t.p(["Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited, the usernames of other users and the names of files you send. They do not contain messages. For more information, review our ",
t.a({href: "https://element.io/privacy", target: "_blank", rel: "noopener"}, "privacy policy"), "."]), t.a({href: "https://element.io/privacy", target: "_blank", rel: "noopener"}, "privacy policy"), "."]),
t.p([])
); );
return t.main({className: "Settings middle"}, [ return t.main({className: "Settings middle"}, [
@ -136,3 +141,37 @@ export class SettingsView extends TemplateView {
})]; })];
} }
} }
async function openLogs(vm) {
// Use vite-specific url so this asset doesn't get picked up by vite and included in the production build,
// as opening the logs is only available during dev time, and @matrixdotorg/structured-logviewer is a dev dependency
// This url is what import "@matrixdotorg/structured-logviewer/index.html?url" resolves to with vite.
const win = window.open(`/@fs/${DEFINE_PROJECT_DIR}/node_modules/@matrixdotorg/structured-logviewer/index.html`);
await new Promise((resolve, reject) => {
let shouldSendPings = true;
const cleanup = () => {
shouldSendPings = false;
window.removeEventListener("message", waitForPong);
};
const waitForPong = event => {
if (event.data.type === "pong") {
cleanup();
resolve();
}
};
const sendPings = async () => {
while (shouldSendPings) {
win.postMessage({type: "ping"});
await new Promise(rr => setTimeout(rr, 50));
if (win.closed) {
cleanup();
}
}
};
window.addEventListener("message", waitForPong);
sendPings().catch(reject);
});
const logs = await vm.exportLogsBlob();
win.postMessage({type: "open", logs: logs.nativeBlob});
}

View file

@ -37,6 +37,8 @@ export default defineConfig(({mode}) => {
"sw": definePlaceholders "sw": definePlaceholders
}), }),
], ],
define: definePlaceholders, define: Object.assign({
DEFINE_PROJECT_DIR: JSON.stringify(__dirname)
}, definePlaceholders),
}); });
}); });

View file

@ -56,6 +56,11 @@
version "3.2.3" version "3.2.3"
resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz#cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4" resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz#cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4"
"@matrixdotorg/structured-logviewer@^0.0.1":
version "0.0.1"
resolved "https://registry.yarnpkg.com/@matrixdotorg/structured-logviewer/-/structured-logviewer-0.0.1.tgz#9c29470b552f874afbb1df16c6e8e9e0c55cbf59"
integrity sha512-IdPYxAFDEoEs2G1ImKCkCxFI3xF1DDctP3N9JOtHRvIPbPPdTT9DyNqKTewCb5zwjNB1mGBrnWyURnHDiOOL3w==
"@nodelib/fs.scandir@2.1.5": "@nodelib/fs.scandir@2.1.5":
version "2.1.5" version "2.1.5"
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"