feat: 添加订阅消息功能
This commit is contained in:
parent
266db3cfa4
commit
3e09131356
@ -37,6 +37,13 @@ $primary-color: #0877F1;
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.patient-basic-info {
|
.patient-basic-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -84,6 +91,26 @@ $primary-color: #0877F1;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.remind-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8rpx 20rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
border: 2rpx solid #3876f6;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remind-btn:active {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remind-btn-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #3876f6;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.badge-processing {
|
.badge-processing {
|
||||||
background: #d1ecf1;
|
background: #d1ecf1;
|
||||||
|
|
||||||
|
|||||||
@ -12,12 +12,17 @@
|
|||||||
>{{ patientInfo.sex }} · {{ patientInfo.age }}岁</text
|
>{{ patientInfo.sex }} · {{ patientInfo.age }}岁</text
|
||||||
>
|
>
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view class="header-actions">
|
||||||
class="status-badge"
|
<view
|
||||||
:class="chatStatusInfo.badgeClass"
|
class="status-badge"
|
||||||
v-if="chatStatusInfo.badgeText"
|
:class="chatStatusInfo.badgeClass"
|
||||||
>
|
v-if="chatStatusInfo.badgeText"
|
||||||
<text class="badge-text">{{ chatStatusInfo.badgeText }}</text>
|
>
|
||||||
|
<text class="badge-text">{{ chatStatusInfo.badgeText }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="remind-btn" @click="handleSubscribeReminder">
|
||||||
|
<text class="remind-btn-text">接收提醒</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@ -190,6 +195,11 @@ import ChatInput from "./components/chat-input.vue";
|
|||||||
import SystemMessage from "./components/system-message.vue";
|
import SystemMessage from "./components/system-message.vue";
|
||||||
import ConsultCancel from "./components/consult-cancel.vue";
|
import ConsultCancel from "./components/consult-cancel.vue";
|
||||||
import ConsultApply from "./components/consult-apply.vue";
|
import ConsultApply from "./components/consult-apply.vue";
|
||||||
|
import { requestConversationSubscribeMessage } from "@/utils/subscribe-message";
|
||||||
|
import {
|
||||||
|
SUBSCRIBE_MESSAGE_ROLE,
|
||||||
|
SUBSCRIBE_MESSAGE_SCENE,
|
||||||
|
} from "@/utils/subscribe-message-config";
|
||||||
|
|
||||||
const timChatManager = globalTimChatManager;
|
const timChatManager = globalTimChatManager;
|
||||||
|
|
||||||
@ -1063,6 +1073,24 @@ const handleApplyConsult = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubscribeReminder = async () => {
|
||||||
|
await requestConversationSubscribeMessage({
|
||||||
|
role: SUBSCRIBE_MESSAGE_ROLE.PATIENT,
|
||||||
|
scene: SUBSCRIBE_MESSAGE_SCENE.CHAT,
|
||||||
|
conversationId: chatInfo.value.conversationID || "",
|
||||||
|
groupId: groupId.value || "",
|
||||||
|
corpId: corpId.value || "",
|
||||||
|
patientId: patientId.value || "",
|
||||||
|
userId: openid.value || account.value?.openid || "",
|
||||||
|
openid: openid.value || account.value?.openid || "",
|
||||||
|
unionid: account.value?.unionid || "",
|
||||||
|
extraData: {
|
||||||
|
orderStatus: orderStatus.value || "",
|
||||||
|
page: "pages/message/index",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 页面卸载
|
// 页面卸载
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
clearMessageCache();
|
clearMessageCache();
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="message-page">
|
<view class="message-page">
|
||||||
|
|
||||||
<!-- 消息列表 -->
|
<!-- 消息列表 -->
|
||||||
<scroll-view class="message-list" scroll-y="true" refresher-enabled :refresher-triggered="refreshing"
|
<scroll-view class="message-list" scroll-y="true" refresher-enabled :refresher-triggered="refreshing"
|
||||||
@refresherrefresh="handleRefresh" @scrolltolower="handleLoadMore">
|
@refresherrefresh="handleRefresh" @scrolltolower="handleLoadMore">
|
||||||
@ -52,6 +51,10 @@
|
|||||||
}}</text>
|
}}</text>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
|
<view class="subscribe-entry" @click="handleSubscribeReminder">
|
||||||
|
<text class="subscribe-entry-text">接收提醒</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -65,9 +68,16 @@ import { mergeConversationWithGroupDetails } from "@/utils/conversation-merger.j
|
|||||||
import { globalUnreadListenerManager } from "@/utils/global-unread-listener.js";
|
import { globalUnreadListenerManager } from "@/utils/global-unread-listener.js";
|
||||||
import useGroupAvatars from "./hooks/use-group-avatars.js";
|
import useGroupAvatars from "./hooks/use-group-avatars.js";
|
||||||
import GroupAvatar from "@/components/group-avatar.vue";
|
import GroupAvatar from "@/components/group-avatar.vue";
|
||||||
|
import { requestConversationSubscribeMessage } from "@/utils/subscribe-message";
|
||||||
|
import {
|
||||||
|
SUBSCRIBE_MESSAGE_ROLE,
|
||||||
|
SUBSCRIBE_MESSAGE_SCENE,
|
||||||
|
} from "@/utils/subscribe-message-config";
|
||||||
|
|
||||||
// 获取登录状态
|
// 获取登录状态
|
||||||
const { account, openid, isIMInitialized, hasImCorpId } = storeToRefs(useAccountStore());
|
const { account, openid, isIMInitialized, hasImCorpId, teams } = storeToRefs(
|
||||||
|
useAccountStore()
|
||||||
|
);
|
||||||
const { initIMAfterLogin } = useAccountStore();
|
const { initIMAfterLogin } = useAccountStore();
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
@ -492,6 +502,20 @@ const cleanMessageText = (text) => {
|
|||||||
return text.replace(/[\r\n]+/g, " ").trim();
|
return text.replace(/[\r\n]+/g, " ").trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubscribeReminder = async () => {
|
||||||
|
await requestConversationSubscribeMessage({
|
||||||
|
role: SUBSCRIBE_MESSAGE_ROLE.PATIENT,
|
||||||
|
scene: SUBSCRIBE_MESSAGE_SCENE.LIST,
|
||||||
|
corpId: teams.value.find((item) => item?.corpId)?.corpId || "",
|
||||||
|
userId: openid.value || account.value?.openid || "",
|
||||||
|
openid: openid.value || account.value?.openid || "",
|
||||||
|
unionid: account.value?.unionid || "",
|
||||||
|
extraData: {
|
||||||
|
page: "pages/message/message",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 页面显示
|
// 页面显示
|
||||||
onShow(async () => {
|
onShow(async () => {
|
||||||
// 页面显示时刷新 tabBar 徽章
|
// 页面显示时刷新 tabBar 徽章
|
||||||
@ -718,4 +742,32 @@ onUnmounted(() => {
|
|||||||
color: #999;
|
color: #999;
|
||||||
padding-bottom: 10rpx;
|
padding-bottom: 10rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subscribe-entry {
|
||||||
|
position: fixed;
|
||||||
|
right: 32rpx;
|
||||||
|
bottom: 180rpx;
|
||||||
|
width: 116rpx;
|
||||||
|
height: 116rpx;
|
||||||
|
border-radius: 58rpx;
|
||||||
|
background: #fff;
|
||||||
|
border: 2rpx solid #3876f6;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(56, 118, 246, 0.16);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-entry:active {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscribe-entry-text {
|
||||||
|
width: 56rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #3876f6;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -72,7 +72,9 @@ const urlsConfig = {
|
|||||||
getGroupListByGroupId: "getGroupListByGroupId",
|
getGroupListByGroupId: "getGroupListByGroupId",
|
||||||
createConsultGroup: "createConsultGroup",
|
createConsultGroup: "createConsultGroup",
|
||||||
cancelConsultApplication: "cancelConsultApplication",
|
cancelConsultApplication: "cancelConsultApplication",
|
||||||
getGroupList: "getGroupList"
|
getGroupList: "getGroupList",
|
||||||
|
saveConversationSubscribeResult: "saveConversationSubscribeResult",
|
||||||
|
sendConversationSubscribeEvent: "sendConversationSubscribeEvent"
|
||||||
},
|
},
|
||||||
survery: {
|
survery: {
|
||||||
getMiniAppReceivedSurveryList: 'getMiniAppReceivedSurveryList',
|
getMiniAppReceivedSurveryList: 'getMiniAppReceivedSurveryList',
|
||||||
|
|||||||
64
utils/subscribe-message-config.js
Normal file
64
utils/subscribe-message-config.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
const env = __VITE_ENV__;
|
||||||
|
|
||||||
|
export const SUBSCRIBE_MESSAGE_ROLE = {
|
||||||
|
PATIENT: "patient",
|
||||||
|
DOCTOR: "doctor",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SUBSCRIBE_MESSAGE_SCENE = {
|
||||||
|
DEFAULT: "default",
|
||||||
|
LIST: "list",
|
||||||
|
CHAT: "chat",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SUBSCRIBE_MESSAGE_EVENT = {
|
||||||
|
PATIENT_CONSULT_APPLY: "patient_consult_apply",
|
||||||
|
PATIENT_CHAT_MESSAGE: "patient_chat_message",
|
||||||
|
DOCTOR_ACCEPT: "doctor_accept",
|
||||||
|
DOCTOR_REJECT: "doctor_reject",
|
||||||
|
DOCTOR_CHAT_MESSAGE: "doctor_chat_message",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SUBSCRIBE_MESSAGE_TEMPLATES = {
|
||||||
|
consultationReply: {
|
||||||
|
code: "consultationReply",
|
||||||
|
role: SUBSCRIBE_MESSAGE_ROLE.PATIENT,
|
||||||
|
id:
|
||||||
|
env.MP_SUBSCRIBE_TEMPLATE_CONSULT_REPLY ||
|
||||||
|
"VF9AC-7Rr3E1drbxBCrxbC-rLTnidmlNXopKReSAd_w",
|
||||||
|
name: "咨询回复通知",
|
||||||
|
events: [
|
||||||
|
SUBSCRIBE_MESSAGE_EVENT.DOCTOR_ACCEPT,
|
||||||
|
SUBSCRIBE_MESSAGE_EVENT.DOCTOR_REJECT,
|
||||||
|
SUBSCRIBE_MESSAGE_EVENT.DOCTOR_CHAT_MESSAGE,
|
||||||
|
],
|
||||||
|
fields: ["患者姓名", "回复时间", "回复者", "所属机构"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SUBSCRIBE_MESSAGE_SCENE_TEMPLATE_MAP = {
|
||||||
|
[SUBSCRIBE_MESSAGE_ROLE.PATIENT]: {
|
||||||
|
[SUBSCRIBE_MESSAGE_SCENE.DEFAULT]: ["consultationReply"],
|
||||||
|
[SUBSCRIBE_MESSAGE_SCENE.LIST]: ["consultationReply"],
|
||||||
|
[SUBSCRIBE_MESSAGE_SCENE.CHAT]: ["consultationReply"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveSubscribeTemplates({
|
||||||
|
role,
|
||||||
|
scene = SUBSCRIBE_MESSAGE_SCENE.DEFAULT,
|
||||||
|
} = {}) {
|
||||||
|
const roleMap = SUBSCRIBE_MESSAGE_SCENE_TEMPLATE_MAP[role] || {};
|
||||||
|
const keys =
|
||||||
|
roleMap[scene] || roleMap[SUBSCRIBE_MESSAGE_SCENE.DEFAULT] || [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
return keys
|
||||||
|
.map((key) => SUBSCRIBE_MESSAGE_TEMPLATES[key])
|
||||||
|
.filter((item) => item && item.id)
|
||||||
|
.filter((item) => {
|
||||||
|
if (seen.has(item.id)) return false;
|
||||||
|
seen.add(item.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
194
utils/subscribe-message.js
Normal file
194
utils/subscribe-message.js
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
import api from "@/utils/api";
|
||||||
|
import { toast } from "@/utils/widget";
|
||||||
|
import { resolveSubscribeTemplates } from "./subscribe-message-config";
|
||||||
|
|
||||||
|
const SUBSCRIBE_ACCEPT_STATUS = "accept";
|
||||||
|
const SUBSCRIBE_REJECT_STATUS = "reject";
|
||||||
|
const SUBSCRIBE_BAN_STATUS = "ban";
|
||||||
|
const SUBSCRIBE_FILTER_STATUS = "filter";
|
||||||
|
const SUBSCRIBE_CANCEL_STATUS = "cancel";
|
||||||
|
const SUBSCRIBE_FAILED_STATUS = "failed";
|
||||||
|
|
||||||
|
function canUseSubscribeMessage() {
|
||||||
|
return (
|
||||||
|
typeof wx !== "undefined" &&
|
||||||
|
typeof wx.requestSubscribeMessage === "function"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSubscribeMessage(tmplIds = []) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
wx.requestSubscribeMessage({
|
||||||
|
tmplIds,
|
||||||
|
success(res) {
|
||||||
|
resolve({ ok: true, res });
|
||||||
|
},
|
||||||
|
fail(err) {
|
||||||
|
resolve({ ok: false, err });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFailStatus(err = {}) {
|
||||||
|
const errCode = Number(err.errCode || 0);
|
||||||
|
const errMsg = String(err.errMsg || "").toLowerCase();
|
||||||
|
|
||||||
|
if (errCode === 20004 || errMsg.includes("main switch")) {
|
||||||
|
return SUBSCRIBE_BAN_STATUS;
|
||||||
|
}
|
||||||
|
if (errCode === 20005 || errMsg.includes("ban")) {
|
||||||
|
return SUBSCRIBE_BAN_STATUS;
|
||||||
|
}
|
||||||
|
if (errMsg.includes("filter")) {
|
||||||
|
return SUBSCRIBE_FILTER_STATUS;
|
||||||
|
}
|
||||||
|
if (errMsg.includes("cancel")) {
|
||||||
|
return SUBSCRIBE_CANCEL_STATUS;
|
||||||
|
}
|
||||||
|
return SUBSCRIBE_FAILED_STATUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTemplateResultRecords(templates = [], requestResult = {}, context = {}) {
|
||||||
|
const requestedAt = Date.now();
|
||||||
|
|
||||||
|
if (requestResult.ok) {
|
||||||
|
const res = requestResult.res || {};
|
||||||
|
return templates.map((template) => ({
|
||||||
|
role: context.role || "",
|
||||||
|
scene: context.scene || "",
|
||||||
|
conversationId: context.conversationId || "",
|
||||||
|
groupId: context.groupId || "",
|
||||||
|
corpId: context.corpId || "",
|
||||||
|
teamId: context.teamId || "",
|
||||||
|
patientId: context.patientId || "",
|
||||||
|
doctorId: context.doctorId || "",
|
||||||
|
userId: context.userId || "",
|
||||||
|
openid: context.openid || "",
|
||||||
|
unionid: context.unionid || "",
|
||||||
|
templateId: template.id,
|
||||||
|
templateCode: template.code,
|
||||||
|
templateName: template.name,
|
||||||
|
eventTypes: template.events,
|
||||||
|
status: String(res[template.id] || SUBSCRIBE_FAILED_STATUS),
|
||||||
|
rawResult: res,
|
||||||
|
requestedAt,
|
||||||
|
extraData: context.extraData || {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = normalizeFailStatus(requestResult.err);
|
||||||
|
return templates.map((template) => ({
|
||||||
|
role: context.role || "",
|
||||||
|
scene: context.scene || "",
|
||||||
|
conversationId: context.conversationId || "",
|
||||||
|
groupId: context.groupId || "",
|
||||||
|
corpId: context.corpId || "",
|
||||||
|
teamId: context.teamId || "",
|
||||||
|
patientId: context.patientId || "",
|
||||||
|
doctorId: context.doctorId || "",
|
||||||
|
userId: context.userId || "",
|
||||||
|
openid: context.openid || "",
|
||||||
|
unionid: context.unionid || "",
|
||||||
|
templateId: template.id,
|
||||||
|
templateCode: template.code,
|
||||||
|
templateName: template.name,
|
||||||
|
eventTypes: template.events,
|
||||||
|
status,
|
||||||
|
rawResult: requestResult.err || {},
|
||||||
|
requestedAt,
|
||||||
|
extraData: context.extraData || {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildToastMessage(records = [], reportResult = { success: false }) {
|
||||||
|
const accepted = records.some((item) => item.status === SUBSCRIBE_ACCEPT_STATUS);
|
||||||
|
|
||||||
|
if (accepted && reportResult?.success === false) {
|
||||||
|
return reportResult?.message || "提醒开启失败,请稍后再试";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (records.some((item) => item.status === SUBSCRIBE_ACCEPT_STATUS)) {
|
||||||
|
return "会话消息提醒开启";
|
||||||
|
}
|
||||||
|
if (records.some((item) => item.status === SUBSCRIBE_BAN_STATUS)) {
|
||||||
|
return "请先在微信设置中开启订阅消息提醒";
|
||||||
|
}
|
||||||
|
if (records.some((item) => item.status === SUBSCRIBE_FILTER_STATUS)) {
|
||||||
|
return "当前提醒模板暂不可用";
|
||||||
|
}
|
||||||
|
if (records.some((item) => item.status === SUBSCRIBE_REJECT_STATUS)) {
|
||||||
|
return "你已拒绝本次提醒订阅";
|
||||||
|
}
|
||||||
|
if (records.some((item) => item.status === SUBSCRIBE_CANCEL_STATUS)) {
|
||||||
|
return "你已取消本次提醒订阅";
|
||||||
|
}
|
||||||
|
return "提醒订阅请求失败,请稍后再试";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reportSubscribeResult(records = []) {
|
||||||
|
if (!records.length) return { success: false };
|
||||||
|
try {
|
||||||
|
return await api(
|
||||||
|
"saveConversationSubscribeResult",
|
||||||
|
{
|
||||||
|
records,
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("保存订阅结果失败:", error);
|
||||||
|
return { success: false, message: error?.message || "保存失败" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestConversationSubscribeMessage(context = {}) {
|
||||||
|
const templates = resolveSubscribeTemplates({
|
||||||
|
role: context.role,
|
||||||
|
scene: context.scene,
|
||||||
|
});
|
||||||
|
const requestTemplates = templates.slice(0, 1);
|
||||||
|
|
||||||
|
if (!requestTemplates.length) {
|
||||||
|
await toast("暂未配置提醒模板");
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "template_missing",
|
||||||
|
records: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canUseSubscribeMessage()) {
|
||||||
|
await toast("当前微信版本不支持订阅消息");
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code: "unsupported",
|
||||||
|
records: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestResult = await requestSubscribeMessage(
|
||||||
|
requestTemplates.map((item) => item.id)
|
||||||
|
);
|
||||||
|
const records = buildTemplateResultRecords(
|
||||||
|
requestTemplates,
|
||||||
|
requestResult,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
|
||||||
|
const reportResult = await reportSubscribeResult(records);
|
||||||
|
await toast(buildToastMessage(records, reportResult));
|
||||||
|
|
||||||
|
const subscribeSuccess =
|
||||||
|
records.some((item) => item.status === SUBSCRIBE_ACCEPT_STATUS) &&
|
||||||
|
reportResult?.success !== false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: subscribeSuccess,
|
||||||
|
reportResult,
|
||||||
|
records,
|
||||||
|
acceptedTemplateIds: records
|
||||||
|
.filter((item) => item.status === SUBSCRIBE_ACCEPT_STATUS)
|
||||||
|
.map((item) => item.templateId),
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user