mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-05 19:24:39 +00:00
feat: notifications open the message #10
This commit is contained in:
+8
-21
@@ -13,11 +13,12 @@ import { StorageChangeHandler } from '@/seqta/utils/listeners/StorageChanges'
|
|||||||
import { eventManager } from '@/seqta/utils/listeners/EventManager'
|
import { eventManager } from '@/seqta/utils/listeners/EventManager'
|
||||||
|
|
||||||
// UI and theme management
|
// UI and theme management
|
||||||
import loading, { AppendLoadingSymbol } from '@/seqta/ui/Loading'
|
import RegisterClickListeners from './seqta/utils/listeners/ClickListeners'
|
||||||
import { enableCurrentTheme } from '@/seqta/ui/themes/enableCurrent'
|
|
||||||
import { updateAllColors } from '@/seqta/ui/colors/Manager'
|
|
||||||
import { SettingsResizer } from '@/seqta/ui/SettingsResizer'
|
|
||||||
import { AddBetterSEQTAElements } from '@/seqta/ui/AddBetterSEQTAElements'
|
import { AddBetterSEQTAElements } from '@/seqta/ui/AddBetterSEQTAElements'
|
||||||
|
import { enableCurrentTheme } from '@/seqta/ui/themes/enableCurrent'
|
||||||
|
import loading, { AppendLoadingSymbol } from '@/seqta/ui/Loading'
|
||||||
|
import { SettingsResizer } from '@/seqta/ui/SettingsResizer'
|
||||||
|
import { updateAllColors } from '@/seqta/ui/colors/Manager'
|
||||||
|
|
||||||
// JSON content
|
// JSON content
|
||||||
import MenuitemSVGKey from '@/seqta/content/MenuItemSVGKey.json'
|
import MenuitemSVGKey from '@/seqta/content/MenuItemSVGKey.json'
|
||||||
@@ -38,7 +39,6 @@ import documentLoadCSS from '@/css/documentload.scss?inline'
|
|||||||
import renderSvelte from '@/interface/main'
|
import renderSvelte from '@/interface/main'
|
||||||
import Settings from '@/interface/pages/settings.svelte'
|
import Settings from '@/interface/pages/settings.svelte'
|
||||||
import { settingsPopup } from './interface/hooks/SettingsPopup'
|
import { settingsPopup } from './interface/hooks/SettingsPopup'
|
||||||
import ReactFiber from './seqta/utils/ReactFiber'
|
|
||||||
|
|
||||||
let SettingsClicked = false
|
let SettingsClicked = false
|
||||||
export let MenuOptionsOpen = false
|
export let MenuOptionsOpen = false
|
||||||
@@ -74,6 +74,7 @@ async function init() {
|
|||||||
await initializeSettingsState();
|
await initializeSettingsState();
|
||||||
|
|
||||||
if (settingsState.onoff) {
|
if (settingsState.onoff) {
|
||||||
|
browser.runtime.sendMessage({ type: 'injectMainScript' })
|
||||||
enableCurrentTheme()
|
enableCurrentTheme()
|
||||||
|
|
||||||
if (typeof settingsState.assessmentsAverage == 'undefined') {
|
if (typeof settingsState.assessmentsAverage == 'undefined') {
|
||||||
@@ -91,22 +92,6 @@ async function init() {
|
|||||||
}
|
}
|
||||||
console.info('[BetterSEQTA+] Successfully initalised BetterSEQTA+, starting to load assets.')
|
console.info('[BetterSEQTA+] Successfully initalised BetterSEQTA+, starting to load assets.')
|
||||||
main()
|
main()
|
||||||
|
|
||||||
/* waitForElm('.Viewer__Viewer___32BH-', true).then(async () => {
|
|
||||||
console.log('Element exists')
|
|
||||||
|
|
||||||
await browser.runtime.sendMessage({ type: 'injectMainScript' })
|
|
||||||
|
|
||||||
const nice = ReactFiber.find(".Viewer__Viewer___32BH-", { debug: true })
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
console.log(nice.getState())
|
|
||||||
nice.setState({ selected: new Set([999431]) })
|
|
||||||
|
|
||||||
|
|
||||||
//console.log(nice)
|
|
||||||
}) */
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
@@ -804,6 +789,8 @@ async function LoadPageElements(): Promise<void> {
|
|||||||
}, handleAssessments);
|
}, handleAssessments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RegisterClickListeners();
|
||||||
|
|
||||||
await handleSublink(sublink);
|
await handleSublink(sublink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+150
-104
@@ -1,120 +1,163 @@
|
|||||||
// pageState.ts
|
|
||||||
class ReactFiber {
|
class ReactFiber {
|
||||||
constructor(selector, options = {}) {
|
constructor(selector, options = {}) {
|
||||||
this.selector = selector;
|
this.selector = selector;
|
||||||
this.debug = options.debug || false;
|
this.debug = options.debug || false;
|
||||||
this.nodes = [...document.querySelectorAll(selector)]; // Support multiple elements
|
this.nodes = [...document.querySelectorAll(selector)]; // Support multiple elements
|
||||||
this.fibers = this.nodes.map(node => this.getFiberNode(node));
|
this.fibers = this.nodes.map(node => this.getFiberNode(node));
|
||||||
this.components = this.fibers.map(fiber => this.getOwnerComponent(fiber));
|
this.components = this.fibers.map(fiber => this.getOwnerComponent(fiber));
|
||||||
|
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log("📌 Selected Nodes:", this.nodes);
|
console.log("Selected Nodes:", this.nodes);
|
||||||
console.log("🔍 Found Fibers:", this.fibers);
|
console.log("🔍 Found Fibers:", this.fibers);
|
||||||
console.log("🛠 Found Components:", this.components);
|
console.log("🛠 Found Components:", this.components);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static find(selector, options = {}) {
|
static find(selector, options = {}) {
|
||||||
return new ReactFiber(selector, options);
|
return new ReactFiber(selector, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
getFiberNode(node) {
|
getFiberNode(node) {
|
||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
const fiberKey = Object.getOwnPropertyNames(node).find(name =>
|
const fiberKey = Object.getOwnPropertyNames(node).find(name =>
|
||||||
name.startsWith('__reactFiber') || name.startsWith('__reactInternalInstance')
|
name.startsWith('__reactFiber') || name.startsWith('__reactInternalInstance')
|
||||||
);
|
);
|
||||||
return fiberKey ? node[fiberKey] : null;
|
return fiberKey ? node[fiberKey] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getOwnerComponent(fiberNode) {
|
getOwnerComponent(fiberNode) {
|
||||||
let current = fiberNode;
|
let current = fiberNode;
|
||||||
while (current) {
|
while (current) {
|
||||||
if (current.stateNode && (current.stateNode.setState || current.stateNode.forceUpdate)) {
|
if (current.stateNode && (current.stateNode.setState || current.stateNode.forceUpdate)) {
|
||||||
return current.stateNode;
|
return current.stateNode;
|
||||||
}
|
|
||||||
current = current.return;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getState(key) {
|
|
||||||
if (!this.components.length) return null;
|
|
||||||
const state = this.components[0]?.state || null;
|
|
||||||
|
|
||||||
if (key === undefined) {
|
|
||||||
return state; // Return entire state
|
|
||||||
} else if (typeof key === 'string') {
|
|
||||||
return state?.[key]; // Return single key
|
|
||||||
} else if (Array.isArray(key)) {
|
|
||||||
// Return object with only specified keys
|
|
||||||
const filteredState = {};
|
|
||||||
for (const k of key) {
|
|
||||||
if (state && Object.hasOwn(state, k)) { // Use Object.hasOwn for safety
|
|
||||||
filteredState[k] = state[k];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filteredState;
|
|
||||||
}
|
}
|
||||||
return null; // Invalid key type
|
current = current.return;
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
setState(update) {
|
getState(key) {
|
||||||
this.components.forEach(component => {
|
if (!this.components.length) return null;
|
||||||
if (component?.setState) {
|
const state = this.components[0]?.state || null;
|
||||||
if (typeof update === 'function') {
|
|
||||||
// Functional update
|
if (key === undefined) {
|
||||||
component.setState(prevState => {
|
return state;
|
||||||
const newState = update(prevState);
|
} else if (typeof key === 'string') {
|
||||||
if (this.debug) console.log("✅ Updated State (Functional):", newState);
|
return state?.[key];
|
||||||
return newState;
|
} else if (Array.isArray(key)) {
|
||||||
});
|
const filteredState = {};
|
||||||
} else {
|
for (const k of key) {
|
||||||
// Object update (merge with existing state)
|
if (state && Object.hasOwn(state, k)) {
|
||||||
component.setState(prevState => {
|
filteredState[k] = state[k];
|
||||||
const newState = { ...prevState, ...update }; // Merge here!
|
|
||||||
if (this.debug) console.log("✅ Updated State (Object Merge):", newState);
|
|
||||||
return newState;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
return this;
|
return filteredState;
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
getProp(propName) {
|
setState(update) {
|
||||||
if (!this.fibers.length) return null;
|
this.components.forEach(component => {
|
||||||
return this.fibers[0]?.memoizedProps?.[propName];
|
if (component?.setState) {
|
||||||
}
|
if (typeof update === 'function') {
|
||||||
|
// Functional update
|
||||||
setProp(propName) {
|
component.setState(prevState => {
|
||||||
this.fibers.forEach(fiber => {
|
const newState = update(prevState);
|
||||||
if (fiber?.memoizedProps) {
|
if (this.debug) console.log("✅ Updated State (Functional):", newState);
|
||||||
fiber.memoizedProps[propName] = value;
|
return newState;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Object update (merge with existing state)
|
||||||
|
component.setState(prevState => {
|
||||||
|
const newState = {
|
||||||
|
...prevState,
|
||||||
|
...update
|
||||||
|
};
|
||||||
|
if (this.debug) console.log("✅ Updated State (Object Merge):", newState);
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
return this; // Enable chaining
|
});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProp(propName) {
|
||||||
|
if (!this.fibers.length) return null;
|
||||||
|
|
||||||
|
if (propName === undefined) {
|
||||||
|
return this.fibers[0]?.memoizedProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
forceUpdate() {
|
return this.fibers[0]?.memoizedProps?.[propName];
|
||||||
this.components.forEach(component => {
|
}
|
||||||
if (component?.forceUpdate) {
|
|
||||||
component.forceUpdate();
|
setProp(propName) {
|
||||||
if (this.debug) console.log("🔄 Forced React Re-render");
|
this.fibers.forEach(fiber => {
|
||||||
}
|
if (fiber?.memoizedProps) {
|
||||||
});
|
fiber.memoizedProps[propName] = value;
|
||||||
return this; // Enable chaining
|
}
|
||||||
}
|
});
|
||||||
|
return this; // Enable chaining
|
||||||
|
}
|
||||||
|
|
||||||
|
forceUpdate() {
|
||||||
|
this.components.forEach(component => {
|
||||||
|
if (component?.forceUpdate) {
|
||||||
|
component.forceUpdate();
|
||||||
|
if (this.debug) console.log("🔄 Forced React Re-render");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return this; // Enable chaining
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Window cat: ", window.cat);
|
function makeSerializable(obj) {
|
||||||
|
if (typeof obj !== 'object' || obj === null) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map(item => makeSerializable(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializableObj = {};
|
||||||
|
for (const key in obj) {
|
||||||
|
if (Object.hasOwn(obj, key)) {
|
||||||
|
let value = obj[key];
|
||||||
|
|
||||||
|
if (typeof value === 'function') {
|
||||||
|
value = '[Function]';
|
||||||
|
} else if (value instanceof HTMLElement) {
|
||||||
|
value = {
|
||||||
|
type: 'HTMLElement',
|
||||||
|
id: value.id,
|
||||||
|
tagName: value.tagName
|
||||||
|
}; // Replace DOM node with ID/tag info
|
||||||
|
} else if (typeof value === 'symbol') {
|
||||||
|
value = value.toString();
|
||||||
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
|
value = makeSerializable(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
serializableObj[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return serializableObj;
|
||||||
|
}
|
||||||
|
|
||||||
// Listen for messages from the background script (via window.postMessage)
|
|
||||||
window.addEventListener('message', (event) => {
|
window.addEventListener('message', (event) => {
|
||||||
console.log(event)
|
|
||||||
|
|
||||||
if (event.data.type === "reactFiberRequest") {
|
if (event.data.type === "reactFiberRequest") {
|
||||||
const { selector, action, payload, debug, messageId } = event.data;
|
const {
|
||||||
const fiberInstance = ReactFiber.find(selector, { debug });
|
selector,
|
||||||
|
action,
|
||||||
|
payload,
|
||||||
|
debug,
|
||||||
|
messageId
|
||||||
|
} = event.data;
|
||||||
|
const fiberInstance = ReactFiber.find(selector, {
|
||||||
|
debug
|
||||||
|
});
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -124,12 +167,12 @@ window.addEventListener('message', (event) => {
|
|||||||
case "setState":
|
case "setState":
|
||||||
// Handle both function and object updates
|
// Handle both function and object updates
|
||||||
if (payload.updateFn) {
|
if (payload.updateFn) {
|
||||||
const updateFn = eval(`(${payload.updateFn})`);
|
const updateFn = eval(`(${payload.updateFn})`);
|
||||||
fiberInstance.setState(updateFn);
|
fiberInstance.setState(updateFn);
|
||||||
} else {
|
} else {
|
||||||
fiberInstance.setState(payload.updateObject);
|
fiberInstance.setState(payload.updateObject);
|
||||||
}
|
}
|
||||||
response = {}; // Acknowledge
|
response = {};
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "getProp":
|
case "getProp":
|
||||||
@@ -137,18 +180,21 @@ window.addEventListener('message', (event) => {
|
|||||||
break;
|
break;
|
||||||
case "setProp":
|
case "setProp":
|
||||||
fiberInstance.setProp(payload.propName, payload.value);
|
fiberInstance.setProp(payload.propName, payload.value);
|
||||||
response = {}; // Acknowledge
|
response = {};
|
||||||
break;
|
break;
|
||||||
case "forceUpdate":
|
case "forceUpdate":
|
||||||
fiberInstance.forceUpdate();
|
fiberInstance.forceUpdate();
|
||||||
response = {}; // Acknowledge
|
response = {};
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn(`[pageState] Unknown action: ${action}`);
|
console.warn(`[pageState] Unknown action: ${action}`);
|
||||||
response = null;
|
response = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the response back to the background script using window.postMessage
|
if (response !== null && typeof response === 'object') {
|
||||||
|
response = makeSerializable(response);
|
||||||
|
}
|
||||||
|
|
||||||
window.postMessage({
|
window.postMessage({
|
||||||
type: "reactFiberResponse",
|
type: "reactFiberResponse",
|
||||||
response,
|
response,
|
||||||
|
|||||||
@@ -1,72 +1,81 @@
|
|||||||
import browser from 'webextension-polyfill';
|
|
||||||
|
|
||||||
class ReactFiber {
|
class ReactFiber {
|
||||||
private selector: string;
|
private selector: string;
|
||||||
private debug: boolean;
|
private debug: boolean;
|
||||||
private messageIdCounter: number = 0; // Counter for unique message IDs
|
private messageIdCounter: number = 0; // Counter for unique message IDs
|
||||||
|
|
||||||
constructor(selector: string, options: { debug?: boolean } = {}) {
|
constructor(selector: string, options: {
|
||||||
|
debug ? : boolean
|
||||||
|
} = {}) {
|
||||||
this.selector = selector;
|
this.selector = selector;
|
||||||
this.debug = options.debug || false;
|
this.debug = options.debug || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
static find(selector: string, options: { debug?: boolean } = {}) {
|
static find(selector: string, options: {
|
||||||
|
debug ? : boolean
|
||||||
|
} = {}) {
|
||||||
return new ReactFiber(selector, options);
|
return new ReactFiber(selector, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendMessage(action: string, payload: any = {}): Promise<any> {
|
private async sendMessage(action: string, payload: any = {}): Promise < any > {
|
||||||
return new Promise((resolve, _) => {
|
return new Promise((resolve, _) => {
|
||||||
const messageId = this.messageIdCounter++;
|
const messageId = this.messageIdCounter++;
|
||||||
const message = {
|
const message = {
|
||||||
type: "reactFiberRequest",
|
type: "reactFiberRequest",
|
||||||
selector: this.selector,
|
selector: this.selector,
|
||||||
action,
|
action,
|
||||||
payload,
|
payload,
|
||||||
debug: this.debug,
|
debug: this.debug,
|
||||||
messageId, // Include the unique message ID
|
messageId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const listener = (response: any) => {
|
const listener = (response: any) => {
|
||||||
if (response.data?.type === 'reactFiberResponse' && response.data?.messageId === messageId) {
|
if (response.data?.type === 'reactFiberResponse' && response.data?.messageId === messageId) {
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.log("Content Received Response:", response.data.response);
|
console.log("Content Received Response:", response.data.response);
|
||||||
}
|
}
|
||||||
resolve(response.data.response);
|
resolve(response.data.response);
|
||||||
window.removeEventListener("message", listener)
|
window.removeEventListener("message", listener)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('message', listener);
|
window.addEventListener('message', listener);
|
||||||
window.postMessage(message, "*");
|
window.postMessage(message, "*");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async getState(key?: string | string[]): Promise<any> { // Type change: allow string or string[]
|
async getState(key ? : string | string[]): Promise < any > {
|
||||||
return this.sendMessage("getState", { key });
|
return this.sendMessage("getState", {
|
||||||
|
key
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setState(update: any | ((prevState: any) => any)): Promise<ReactFiber> {
|
async setState(update: any | ((prevState: any) => any)): Promise < ReactFiber > {
|
||||||
// Now async again.
|
|
||||||
const updateFnString = typeof update === 'function' ? update.toString() : null;
|
const updateFnString = typeof update === 'function' ? update.toString() : null;
|
||||||
const updateObject = typeof update !== 'function' ? update : null;
|
const updateObject = typeof update !== 'function' ? update : null;
|
||||||
|
|
||||||
await this.sendMessage("setState", { updateFn: updateFnString, updateObject });
|
await this.sendMessage("setState", {
|
||||||
|
updateFn: updateFnString,
|
||||||
|
updateObject
|
||||||
|
});
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async getProp(propName: string): Promise<any> {
|
async getProps(propName ? : string): Promise < any > {
|
||||||
return this.sendMessage("getProp", { propName });
|
return this.sendMessage("getProp", {
|
||||||
|
propName
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setProp(propName: string, value: any): Promise<ReactFiber> {
|
async setProp(propName: string, value: any): Promise < ReactFiber > {
|
||||||
// Now async again
|
await this.sendMessage("setProp", {
|
||||||
await this.sendMessage("setProp", { propName, value });
|
propName,
|
||||||
|
value
|
||||||
|
});
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
async forceUpdate(): Promise<ReactFiber> {
|
async forceUpdate(): Promise < ReactFiber > {
|
||||||
// Now async again
|
|
||||||
await this.sendMessage("forceUpdate");
|
await this.sendMessage("forceUpdate");
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { waitForElm } from "@/SEQTA";
|
||||||
|
import ReactFiber from "../ReactFiber";
|
||||||
|
|
||||||
|
const handleNotificationClick = async (target: HTMLElement) => {
|
||||||
|
const notificationItem = target.closest('.notifications__item___2ErJN') as HTMLElement | null;
|
||||||
|
if (!notificationItem) return;
|
||||||
|
|
||||||
|
const buttonType = notificationItem.getAttribute('data-type');
|
||||||
|
if (buttonType !== 'message') return;
|
||||||
|
|
||||||
|
const notificationList = await ReactFiber.find('.notifications__list___rp2L2').getState();
|
||||||
|
const buttonId = notificationItem.getAttribute('data-id');
|
||||||
|
if (!buttonId) return;
|
||||||
|
|
||||||
|
const matchingNotification = notificationList.storeState.notifications.items.find(
|
||||||
|
(item: any) => item.notificationID === parseInt(buttonId)
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForElm('.Viewer__Viewer___32BH-', true, 20);
|
||||||
|
|
||||||
|
// Select the specific direct message
|
||||||
|
ReactFiber.find('.Viewer__Viewer___32BH-').setState({ selected: new Set([matchingNotification.message.messageID]) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const clickListeners = [
|
||||||
|
{
|
||||||
|
selector: '.notifications__item___2ErJN',
|
||||||
|
handler: handleNotificationClick,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const registerClickListeners = () => {
|
||||||
|
console.log("Registering click listeners...");
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
|
clickListeners.forEach(({ selector, handler }) => {
|
||||||
|
if (target.closest(selector)) {
|
||||||
|
handler(target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default registerClickListeners;
|
||||||
Reference in New Issue
Block a user