1.没置顶的消息,到最上面去了 2.无法转发图片 3.用户ID 是一串字母+数字 4.视频无法显示 5.消息无法置顶 6.可以引用文字,无法艾特用户 7.图片点开只能显示一半 8.转发不能多选,只能一个个转发 9.转发有转发提示,需取消
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -425,12 +425,38 @@ function currentMuteSetting(PDO $appDb, string $groupId): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||||
{
|
{
|
||||||
$group = fetchWebGroup($appDb, $groupId);
|
$group = fetchWebGroup($appDb, $groupId);
|
||||||
if (!$group) {
|
if (!$group) {
|
||||||
respond(404, '没有找到该群组');
|
$groupStatement = $imDb->prepare('select * from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
|
||||||
}
|
$groupStatement->execute([$groupId]);
|
||||||
|
$nativeGroup = $groupStatement->fetch();
|
||||||
|
$memberStatement = $imDb->prepare('select `_type`, `_alias` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
|
||||||
|
$memberStatement->execute([$groupId, $userId]);
|
||||||
|
$nativeMember = $memberStatement->fetch();
|
||||||
|
if (!$nativeGroup || !$nativeMember) {
|
||||||
|
respond(403, '你不在该群组中');
|
||||||
|
}
|
||||||
|
$ownerStatement = $imDb->prepare('select `_mid` from `t_group_member` where `_gid` = ? and `_type` = 2 limit 1');
|
||||||
|
$ownerStatement->execute([$groupId]);
|
||||||
|
$ownerId = (string)($ownerStatement->fetchColumn() ?: '');
|
||||||
|
$users = fetchWebUserMap($imDb, array_filter([$ownerId, $userId]), $userId);
|
||||||
|
return ['group' => [
|
||||||
|
'groupId' => $groupId,
|
||||||
|
'publicId' => (string)($nativeGroup['id'] ?? ''),
|
||||||
|
'name' => (string)($nativeGroup['_name'] ?? $groupId),
|
||||||
|
'category' => 'group',
|
||||||
|
'announcement' => (string)($nativeGroup['_bulletin'] ?? ''),
|
||||||
|
'welcomeText' => '',
|
||||||
|
'ownerUid' => $ownerId,
|
||||||
|
'ownerName' => (string)($users[$ownerId]['displayName'] ?? $ownerId),
|
||||||
|
'myRole' => ((int)($nativeMember['_type'] ?? 0) === 2 ? 'owner' : ((int)($nativeMember['_type'] ?? 0) === 1 ? 'admin' : 'member')),
|
||||||
|
'myAlias' => (string)($nativeMember['_alias'] ?? ''),
|
||||||
|
'myDisplayName' => (string)($nativeMember['_alias'] ?? '') ?: (string)($users[$userId]['displayName'] ?? $userId),
|
||||||
|
'muteSetting' => ['enabled' => false, 'mode' => 'off'],
|
||||||
|
]];
|
||||||
|
}
|
||||||
$member = fetchWebGroupMember($appDb, $groupId, $userId);
|
$member = fetchWebGroupMember($appDb, $groupId, $userId);
|
||||||
if (!$member) {
|
if (!$member) {
|
||||||
respond(403, '你不在该群组中');
|
respond(403, '你不在该群组中');
|
||||||
@@ -443,8 +469,9 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
|||||||
$myAlias = (string)($member['alias'] ?? '');
|
$myAlias = (string)($member['alias'] ?? '');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'group' => [
|
'group' => [
|
||||||
'groupId' => (string)$group['group_id'],
|
'groupId' => (string)$group['group_id'],
|
||||||
|
'publicId' => (string)($group['id'] ?? ''),
|
||||||
'name' => (string)$group['name'],
|
'name' => (string)$group['name'],
|
||||||
'category' => (string)$group['category'],
|
'category' => (string)$group['category'],
|
||||||
'announcement' => (string)($group['announcement'] ?? ''),
|
'announcement' => (string)($group['announcement'] ?? ''),
|
||||||
@@ -459,11 +486,42 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||||
{
|
{
|
||||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||||
respond(403, '你不在该群组中');
|
$membership = $imDb->prepare('select 1 from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
|
||||||
}
|
$membership->execute([$groupId, $userId]);
|
||||||
|
if (!$membership->fetchColumn()) {
|
||||||
|
respond(403, '你不在该群组中');
|
||||||
|
}
|
||||||
|
$nativeStatement = $imDb->prepare(
|
||||||
|
'select `_mid` as `user_id`, `_type`, `_alias`, `_create_dt` from `t_group_member`'
|
||||||
|
. ' where `_gid` = ? order by (`_type` = 2) desc, `_create_dt` asc'
|
||||||
|
);
|
||||||
|
$nativeStatement->execute([$groupId]);
|
||||||
|
$nativeMembers = $nativeStatement->fetchAll();
|
||||||
|
$nativeUsers = fetchWebUserMap($imDb, array_column($nativeMembers, 'user_id'), $userId);
|
||||||
|
return ['members' => array_map(static function (array $member) use ($nativeUsers): array {
|
||||||
|
$uid = (string)$member['user_id'];
|
||||||
|
$user = $nativeUsers[$uid] ?? ['displayName' => $uid, 'mobile' => '', 'portrait' => '', 'isContact' => false];
|
||||||
|
$alias = trim((string)($member['_alias'] ?? ''));
|
||||||
|
$type = (int)($member['_type'] ?? 0);
|
||||||
|
return [
|
||||||
|
'uid' => $uid,
|
||||||
|
'role' => $type === 2 ? 'owner' : ($type === 1 ? 'admin' : 'member'),
|
||||||
|
'alias' => $alias,
|
||||||
|
'contactAlias' => trim((string)($user['contactAlias'] ?? '')),
|
||||||
|
'friendAlias' => trim((string)($user['friendAlias'] ?? '')),
|
||||||
|
'remarkAlias' => trim((string)($user['remarkAlias'] ?? '')),
|
||||||
|
'isContact' => (bool)($user['isContact'] ?? false),
|
||||||
|
'displayName' => trim((string)($user['contactAlias'] ?? '')) ?: ($alias ?: (string)$user['displayName']),
|
||||||
|
'rawDisplayName' => (string)$user['displayName'],
|
||||||
|
'groupDisplayName' => $alias,
|
||||||
|
'mobile' => (string)($user['mobile'] ?? ''),
|
||||||
|
'portrait' => (string)($user['portrait'] ?? ''),
|
||||||
|
];
|
||||||
|
}, $nativeMembers)];
|
||||||
|
}
|
||||||
|
|
||||||
$statement = $appDb->prepare(
|
$statement = $appDb->prepare(
|
||||||
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
|
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ require __DIR__ . '/im_web_bootstrap.php';
|
|||||||
const WEB_MEDIA_PROXY_MAX_BYTES = 15 * 1024 * 1024;
|
const WEB_MEDIA_PROXY_MAX_BYTES = 15 * 1024 * 1024;
|
||||||
const WEB_MEDIA_PROXY_MAX_SECONDS = 25;
|
const WEB_MEDIA_PROXY_MAX_SECONDS = 25;
|
||||||
|
|
||||||
|
function mediaProxyEnv(string $name, string $default = ''): string
|
||||||
|
{
|
||||||
|
if (function_exists('webImEnv')) {
|
||||||
|
return (string)webImEnv($name, $default);
|
||||||
|
}
|
||||||
|
$value = getenv($name);
|
||||||
|
if ($value === false || trim((string)$value) === '') {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
return trim((string)$value);
|
||||||
|
}
|
||||||
|
|
||||||
function failMediaProxy(int $status, string $message): never
|
function failMediaProxy(int $status, string $message): never
|
||||||
{
|
{
|
||||||
http_response_code($status);
|
http_response_code($status);
|
||||||
@@ -18,12 +30,13 @@ function failMediaProxy(int $status, string $message): never
|
|||||||
|
|
||||||
function mediaProxyAllowedHosts(): array
|
function mediaProxyAllowedHosts(): array
|
||||||
{
|
{
|
||||||
$configured = webImEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
|
$configured = mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_HOSTS');
|
||||||
$hosts = array_map(
|
$hosts = array_map(
|
||||||
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
|
static fn(string $host): string => strtolower(rtrim(trim($host), '.')),
|
||||||
preg_split('/[,\s]+/', $configured) ?: []
|
preg_split('/[,\s]+/', $configured) ?: []
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$hosts[] = 'zhiyanim.dfpnm.cn';
|
||||||
return array_values(array_unique(array_filter($hosts)));
|
return array_values(array_unique(array_filter($hosts)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,9 +66,12 @@ function validatedMediaProxyUrl(string $url): array
|
|||||||
|
|
||||||
function mediaProxyCacheRoot(): string
|
function mediaProxyCacheRoot(): string
|
||||||
{
|
{
|
||||||
$root = rtrim(webImEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
|
$root = rtrim(mediaProxyEnv('JC_WEB_IM_MEDIA_CACHE_ROOT', '/var/cache/jc-web-im-media'), '/');
|
||||||
if ($root === '' || !is_dir($root) || !is_writable($root)) {
|
if ($root === '' || !is_dir($root) || !is_writable($root)) {
|
||||||
failMediaProxy(503, '媒体缓存目录不可用');
|
$root = rtrim(sys_get_temp_dir(), '/') . '/jc-web-im-media';
|
||||||
|
if (!is_dir($root) && !@mkdir($root, 0755, true) && !is_dir($root)) {
|
||||||
|
failMediaProxy(503, '媒体缓存目录不可用');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $root;
|
return $root;
|
||||||
@@ -63,7 +79,7 @@ function mediaProxyCacheRoot(): string
|
|||||||
|
|
||||||
function mediaProxyPreferredIps(): array
|
function mediaProxyPreferredIps(): array
|
||||||
{
|
{
|
||||||
$configured = preg_split('/[,\s]+/', webImEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
|
$configured = preg_split('/[,\s]+/', mediaProxyEnv('JC_WEB_IM_MEDIA_PROXY_IPS')) ?: [];
|
||||||
$ips = [];
|
$ips = [];
|
||||||
foreach ($configured as $ip) {
|
foreach ($configured as $ip) {
|
||||||
$ip = trim($ip);
|
$ip = trim($ip);
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
const WEB_MEDIA_STREAM_ROOT = '/opt/im-server/media';
|
||||||
|
|
||||||
|
function failMediaStream(int $status, string $message): never
|
||||||
|
{
|
||||||
|
http_response_code($status);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
echo $message;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = trim((string)($_GET['url'] ?? ''));
|
||||||
|
if ($url === '') {
|
||||||
|
failMediaStream(400, '缺少视频地址');
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = rawurldecode((string)(parse_url($url, PHP_URL_PATH) ?: ''));
|
||||||
|
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
|
||||||
|
failMediaStream(400, '视频地址不合法');
|
||||||
|
}
|
||||||
|
|
||||||
|
$root = realpath(WEB_MEDIA_STREAM_ROOT);
|
||||||
|
$fullPath = realpath(rtrim(WEB_MEDIA_STREAM_ROOT, '/') . $path);
|
||||||
|
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
|
||||||
|
failMediaStream(404, '视频文件不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = (int)filesize($fullPath);
|
||||||
|
$mime = (string)(mime_content_type($fullPath) ?: 'video/mp4');
|
||||||
|
if (!str_starts_with($mime, 'video/')) {
|
||||||
|
$extension = strtolower((string)pathinfo($fullPath, PATHINFO_EXTENSION));
|
||||||
|
$mime = $extension === 'webm' ? 'video/webm' : ($extension === 'mov' ? 'video/quicktime' : 'video/mp4');
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = 0;
|
||||||
|
$end = max(0, $size - 1);
|
||||||
|
$range = trim((string)($_SERVER['HTTP_RANGE'] ?? ''));
|
||||||
|
if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches)) {
|
||||||
|
if ($matches[1] !== '') $start = (int)$matches[1];
|
||||||
|
if ($matches[2] !== '') $end = min($end, (int)$matches[2]);
|
||||||
|
if ($start < 0 || $start > $end || $start >= $size) {
|
||||||
|
header('Content-Range: bytes */' . $size);
|
||||||
|
failMediaStream(416, '请求范围无效');
|
||||||
|
}
|
||||||
|
http_response_code(206);
|
||||||
|
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
|
||||||
|
}
|
||||||
|
|
||||||
|
$length = $end - $start + 1;
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
header('Content-Length: ' . $length);
|
||||||
|
header('Accept-Ranges: bytes');
|
||||||
|
header('Content-Disposition: inline');
|
||||||
|
header('Cache-Control: public, max-age=86400');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
$stream = fopen($fullPath, 'rb');
|
||||||
|
if ($stream === false) failMediaStream(500, '视频文件无法读取');
|
||||||
|
fseek($stream, $start);
|
||||||
|
$remaining = $length;
|
||||||
|
while ($remaining > 0 && !feof($stream)) {
|
||||||
|
$chunk = fread($stream, min(1024 * 1024, $remaining));
|
||||||
|
if ($chunk === false || $chunk === '') break;
|
||||||
|
echo $chunk;
|
||||||
|
$remaining -= strlen($chunk);
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
fclose($stream);
|
||||||
@@ -78,7 +78,7 @@ function fetchActionMessageRow(string $messageUid): array
|
|||||||
return is_array($row) ? $row : [];
|
return is_array($row) ? $row : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureMessageEditsTable(): void
|
function ensureMessageEditsTable(): void
|
||||||
{
|
{
|
||||||
imDb()->exec(
|
imDb()->exec(
|
||||||
'create table if not exists `t_web_message_edits` ('
|
'create table if not exists `t_web_message_edits` ('
|
||||||
@@ -93,7 +93,55 @@ function ensureMessageEditsTable(): void
|
|||||||
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
|
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
|
||||||
. ') engine=InnoDB default charset=utf8mb4'
|
. ') engine=InnoDB default charset=utf8mb4'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensurePinnedMessagesTable(): void
|
||||||
|
{
|
||||||
|
$db = imAppDb();
|
||||||
|
$db->exec(
|
||||||
|
'create table if not exists `t_web_pinned_messages` ('
|
||||||
|
. '`user_id` varchar(64) not null,'
|
||||||
|
. '`conversation_type` varchar(16) not null,'
|
||||||
|
. '`target_id` varchar(128) not null,'
|
||||||
|
. '`message_uid` varchar(32) not null,'
|
||||||
|
. '`message_json` text null,'
|
||||||
|
. '`updated_at_ms` bigint not null,'
|
||||||
|
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
|
||||||
|
. ') engine=InnoDB default charset=utf8mb4'
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
$db->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
|
||||||
|
} catch (Throwable) {
|
||||||
|
// The column already exists.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid, array $message): void
|
||||||
|
{
|
||||||
|
ensurePinnedMessagesTable();
|
||||||
|
$statement = imAppDb()->prepare(
|
||||||
|
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `message_json`, `updated_at_ms`)'
|
||||||
|
. ' values (?, ?, ?, ?, ?, ?) on duplicate key update `message_uid` = values(`message_uid`),'
|
||||||
|
. ' `message_json` = values(`message_json`), `updated_at_ms` = values(`updated_at_ms`)'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
$userId,
|
||||||
|
$conversationType,
|
||||||
|
$targetId,
|
||||||
|
$messageUid,
|
||||||
|
json_encode($message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
|
nowTimestampMs(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePinnedMessage(string $userId, string $conversationType, string $targetId): void
|
||||||
|
{
|
||||||
|
ensurePinnedMessagesTable();
|
||||||
|
$statement = imAppDb()->prepare(
|
||||||
|
'delete from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ?'
|
||||||
|
);
|
||||||
|
$statement->execute([$userId, $conversationType, $targetId]);
|
||||||
|
}
|
||||||
|
|
||||||
function editTextMessage(string $operatorId, string $messageUid, string $text): array
|
function editTextMessage(string $operatorId, string $messageUid, string $text): array
|
||||||
{
|
{
|
||||||
@@ -222,7 +270,22 @@ if ($action === '' || $messageUid <= 0) {
|
|||||||
respond(400, '缺少操作类型或消息ID');
|
respond(400, '缺少操作类型或消息ID');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if ($action === 'pin' || $action === 'unpin') {
|
||||||
|
if ($userId === '') respond(400, '缺少操作者ID');
|
||||||
|
$targetId = trim((string)($payload['target'] ?? ''));
|
||||||
|
$conversationType = trim((string)($payload['conversationType'] ?? 'single'));
|
||||||
|
if ($targetId === '') respond(400, '缺少会话ID');
|
||||||
|
$userId = resolveActionUserId($userId);
|
||||||
|
if ($action === 'pin') {
|
||||||
|
$message = is_array($payload['message'] ?? null) ? $payload['message'] : [];
|
||||||
|
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText, $message);
|
||||||
|
} else {
|
||||||
|
removePinnedMessage($userId, $conversationType, $targetId);
|
||||||
|
}
|
||||||
|
respond(0, 'success', ['messageUid' => $messageUidText, 'action' => $action]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($action === 'recall') {
|
if ($action === 'recall') {
|
||||||
if ($userId === '') {
|
if ($userId === '') {
|
||||||
respond(400, '缺少操作者ID');
|
respond(400, '缺少操作者ID');
|
||||||
|
|||||||
+1724
-900
File diff suppressed because it is too large
Load Diff
+111
-24
@@ -8,8 +8,9 @@ require __DIR__ . '/im_web_bootstrap.php';
|
|||||||
|
|
||||||
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
||||||
const WEB_MEDIA_PUBLIC_PORT = 18000;
|
const WEB_MEDIA_PUBLIC_PORT = 18000;
|
||||||
const WEB_MEDIA_TYPE_IMAGE = 1;
|
const WEB_MEDIA_TYPE_IMAGE = 1;
|
||||||
const WEB_MEDIA_TYPE_FILE = 4;
|
const WEB_MEDIA_TYPE_VIDEO = 3;
|
||||||
|
const WEB_MEDIA_TYPE_FILE = 4;
|
||||||
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -245,13 +246,32 @@ function storeMessageAttachment(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachmentFromRequest(string $messageType): ?array
|
function attachmentFromRequest(string $messageType): ?array
|
||||||
{
|
{
|
||||||
if (!in_array($messageType, ['image', 'file'], true)) {
|
if (!in_array($messageType, ['image', 'video', 'file'], true)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
|
$forwardUrl = trim((string)($_POST['attachmentUrl'] ?? ''));
|
||||||
|
if ($forwardUrl !== '') {
|
||||||
|
$parts = parse_url($forwardUrl);
|
||||||
|
$scheme = strtolower((string)($parts['scheme'] ?? ''));
|
||||||
|
if (!in_array($scheme, ['http', 'https'], true) || empty($parts['host']) || strlen($forwardUrl) > 2048) {
|
||||||
|
respond(400, '媒体地址不合法');
|
||||||
|
}
|
||||||
|
$mime = trim((string)($_POST['attachmentMime'] ?? 'application/octet-stream'));
|
||||||
|
$name = safeUploadName((string)($_POST['attachmentName'] ?? ($messageType === 'image' ? '图片' : ($messageType === 'video' ? '视频.mp4' : '文件'))));
|
||||||
|
return [
|
||||||
|
'type' => $messageType,
|
||||||
|
'url' => $forwardUrl,
|
||||||
|
'name' => $name,
|
||||||
|
'mime' => $mime,
|
||||||
|
'size' => max(0, (int)($_POST['attachmentSize'] ?? 0)),
|
||||||
|
'mediaType' => $messageType === 'image' ? WEB_MEDIA_TYPE_IMAGE : ($messageType === 'video' ? WEB_MEDIA_TYPE_VIDEO : WEB_MEDIA_TYPE_FILE),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
|
||||||
if (!is_array($attachmentFile)) {
|
if (!is_array($attachmentFile)) {
|
||||||
respond(400, '缺少上传文件');
|
respond(400, '缺少上传文件');
|
||||||
}
|
}
|
||||||
@@ -266,11 +286,15 @@ $sender = messageRequestValue($payload, 'sender');
|
|||||||
$target = messageRequestValue($payload, 'target');
|
$target = messageRequestValue($payload, 'target');
|
||||||
$text = messageRequestValue($payload, 'text');
|
$text = messageRequestValue($payload, 'text');
|
||||||
$messageType = messageRequestValue($payload, 'messageType', 'text');
|
$messageType = messageRequestValue($payload, 'messageType', 'text');
|
||||||
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
|
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
|
||||||
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
|
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
|
||||||
|
$mentionedUserIds = $payload['mentionedUserIds'] ?? [];
|
||||||
|
$mentionedUserIds = is_array($mentionedUserIds)
|
||||||
|
? array_values(array_unique(array_filter(array_map(static fn($value): string => trim((string)$value), $mentionedUserIds))))
|
||||||
|
: [];
|
||||||
$attachment = null;
|
$attachment = null;
|
||||||
|
|
||||||
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'file'], true))) {
|
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'video', 'file'], true))) {
|
||||||
respond(400, '缺少发送者、接收者或消息内容');
|
respond(400, '缺少发送者、接收者或消息内容');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,11 +309,70 @@ try {
|
|||||||
. ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?'
|
. ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?'
|
||||||
. ' limit 1'
|
. ' limit 1'
|
||||||
);
|
);
|
||||||
$memberStatement->execute([$target, $sender]);
|
$memberStatement->execute([$target, $sender]);
|
||||||
$group = $memberStatement->fetch();
|
$group = $memberStatement->fetch();
|
||||||
if (!$group) {
|
if (!$group) {
|
||||||
respond(403, '你不在该群组中');
|
// 手机端创建的原生群不一定存在于 Web 自建群表中,需回退检查 IM 群成员表。
|
||||||
}
|
$imDb = imDb();
|
||||||
|
$nativeGroupStatement = $imDb->prepare(
|
||||||
|
'select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1'
|
||||||
|
);
|
||||||
|
$nativeGroupStatement->execute([$target]);
|
||||||
|
$nativeMemberStatement = $imDb->prepare(
|
||||||
|
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
|
||||||
|
);
|
||||||
|
$nativeMemberStatement->execute([$target, $sender]);
|
||||||
|
$nativeMemberType = $nativeMemberStatement->fetchColumn();
|
||||||
|
if (!$nativeGroupStatement->fetchColumn() || $nativeMemberType === false) {
|
||||||
|
respond(403, '你不在该群组中');
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachment = attachmentFromRequest($messageType);
|
||||||
|
$pushContent = $text !== ''
|
||||||
|
? $text
|
||||||
|
: ($attachment
|
||||||
|
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||||
|
: $text);
|
||||||
|
$payloadBody = [
|
||||||
|
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
|
||||||
|
'searchableContent' => $pushContent,
|
||||||
|
'pushContent' => $pushContent,
|
||||||
|
'content' => $text,
|
||||||
|
'persistFlag' => 3,
|
||||||
|
];
|
||||||
|
if ($mentionedUserIds) {
|
||||||
|
$payloadBody['mentionedType'] = 1;
|
||||||
|
$payloadBody['mentionedTarget'] = $mentionedUserIds;
|
||||||
|
}
|
||||||
|
if ($attachment) {
|
||||||
|
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
||||||
|
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
||||||
|
$payloadBody['extra'] = json_encode([
|
||||||
|
'name' => (string)$attachment['name'],
|
||||||
|
'mime' => (string)$attachment['mime'],
|
||||||
|
'size' => (int)$attachment['size'],
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
$nativeResult = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||||
|
'sender' => $sender,
|
||||||
|
'conv' => ['type' => 1, 'target' => $target, 'line' => 0],
|
||||||
|
'payload' => $payloadBody,
|
||||||
|
'userMessage' => true,
|
||||||
|
]), '原生群消息发送失败');
|
||||||
|
$messageUid = isset($nativeResult['messageUid']) ? (string)$nativeResult['messageUid'] : '';
|
||||||
|
$timestamp = (int)($nativeResult['timestamp'] ?? webImNowMs());
|
||||||
|
if ($attachment && $messageUid !== '') {
|
||||||
|
storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $timestamp);
|
||||||
|
}
|
||||||
|
$role = (int)$nativeMemberType === 2 ? 'owner' : ((int)$nativeMemberType === 1 ? 'admin' : 'member');
|
||||||
|
respond(0, 'success', [
|
||||||
|
'messageUid' => $messageUid,
|
||||||
|
'timestamp' => $timestamp,
|
||||||
|
'senderRole' => $role,
|
||||||
|
'attachment' => $attachment,
|
||||||
|
'nativeError' => '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
$category = (string)($group['category'] ?? 'group');
|
$category = (string)($group['category'] ?? 'group');
|
||||||
$role = (string)($group['role'] ?? 'member');
|
$role = (string)($group['role'] ?? 'member');
|
||||||
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
||||||
@@ -319,15 +402,19 @@ try {
|
|||||||
$pushContent = $text !== ''
|
$pushContent = $text !== ''
|
||||||
? $text
|
? $text
|
||||||
: ($attachment
|
: ($attachment
|
||||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||||
: $text);
|
: $text);
|
||||||
$payloadBody = [
|
$payloadBody = [
|
||||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
|
||||||
'searchableContent' => $pushContent,
|
'searchableContent' => $pushContent,
|
||||||
'pushContent' => $pushContent,
|
'pushContent' => $pushContent,
|
||||||
'content' => $text,
|
'content' => $text,
|
||||||
'persistFlag' => 3,
|
'persistFlag' => 3,
|
||||||
];
|
];
|
||||||
|
if ($mentionedUserIds) {
|
||||||
|
$payloadBody['mentionedType'] = 1;
|
||||||
|
$payloadBody['mentionedTarget'] = $mentionedUserIds;
|
||||||
|
}
|
||||||
if ($attachment) {
|
if ($attachment) {
|
||||||
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
||||||
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
||||||
@@ -364,10 +451,10 @@ try {
|
|||||||
$pushContent = $text !== ''
|
$pushContent = $text !== ''
|
||||||
? $text
|
? $text
|
||||||
: ($attachment
|
: ($attachment
|
||||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||||
: $text);
|
: $text);
|
||||||
$payloadBody = [
|
$payloadBody = [
|
||||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
|
||||||
'searchableContent' => $pushContent,
|
'searchableContent' => $pushContent,
|
||||||
'pushContent' => $pushContent,
|
'pushContent' => $pushContent,
|
||||||
'content' => $text,
|
'content' => $text,
|
||||||
|
|||||||
+33
-22
File diff suppressed because one or more lines are too long
Vendored
+71
-13
@@ -425,12 +425,38 @@ function currentMuteSetting(PDO $appDb, string $groupId): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||||
{
|
{
|
||||||
$group = fetchWebGroup($appDb, $groupId);
|
$group = fetchWebGroup($appDb, $groupId);
|
||||||
if (!$group) {
|
if (!$group) {
|
||||||
respond(404, '没有找到该群组');
|
$groupStatement = $imDb->prepare('select * from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
|
||||||
}
|
$groupStatement->execute([$groupId]);
|
||||||
|
$nativeGroup = $groupStatement->fetch();
|
||||||
|
$memberStatement = $imDb->prepare('select `_type`, `_alias` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
|
||||||
|
$memberStatement->execute([$groupId, $userId]);
|
||||||
|
$nativeMember = $memberStatement->fetch();
|
||||||
|
if (!$nativeGroup || !$nativeMember) {
|
||||||
|
respond(403, '你不在该群组中');
|
||||||
|
}
|
||||||
|
$ownerStatement = $imDb->prepare('select `_mid` from `t_group_member` where `_gid` = ? and `_type` = 2 limit 1');
|
||||||
|
$ownerStatement->execute([$groupId]);
|
||||||
|
$ownerId = (string)($ownerStatement->fetchColumn() ?: '');
|
||||||
|
$users = fetchWebUserMap($imDb, array_filter([$ownerId, $userId]), $userId);
|
||||||
|
return ['group' => [
|
||||||
|
'groupId' => $groupId,
|
||||||
|
'publicId' => (string)($nativeGroup['id'] ?? ''),
|
||||||
|
'name' => (string)($nativeGroup['_name'] ?? $groupId),
|
||||||
|
'category' => 'group',
|
||||||
|
'announcement' => (string)($nativeGroup['_bulletin'] ?? ''),
|
||||||
|
'welcomeText' => '',
|
||||||
|
'ownerUid' => $ownerId,
|
||||||
|
'ownerName' => (string)($users[$ownerId]['displayName'] ?? $ownerId),
|
||||||
|
'myRole' => ((int)($nativeMember['_type'] ?? 0) === 2 ? 'owner' : ((int)($nativeMember['_type'] ?? 0) === 1 ? 'admin' : 'member')),
|
||||||
|
'myAlias' => (string)($nativeMember['_alias'] ?? ''),
|
||||||
|
'myDisplayName' => (string)($nativeMember['_alias'] ?? '') ?: (string)($users[$userId]['displayName'] ?? $userId),
|
||||||
|
'muteSetting' => ['enabled' => false, 'mode' => 'off'],
|
||||||
|
]];
|
||||||
|
}
|
||||||
$member = fetchWebGroupMember($appDb, $groupId, $userId);
|
$member = fetchWebGroupMember($appDb, $groupId, $userId);
|
||||||
if (!$member) {
|
if (!$member) {
|
||||||
respond(403, '你不在该群组中');
|
respond(403, '你不在该群组中');
|
||||||
@@ -443,8 +469,9 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
|||||||
$myAlias = (string)($member['alias'] ?? '');
|
$myAlias = (string)($member['alias'] ?? '');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'group' => [
|
'group' => [
|
||||||
'groupId' => (string)$group['group_id'],
|
'groupId' => (string)$group['group_id'],
|
||||||
|
'publicId' => (string)($group['id'] ?? ''),
|
||||||
'name' => (string)$group['name'],
|
'name' => (string)$group['name'],
|
||||||
'category' => (string)$group['category'],
|
'category' => (string)$group['category'],
|
||||||
'announcement' => (string)($group['announcement'] ?? ''),
|
'announcement' => (string)($group['announcement'] ?? ''),
|
||||||
@@ -459,11 +486,42 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||||
{
|
{
|
||||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||||
respond(403, '你不在该群组中');
|
$membership = $imDb->prepare('select 1 from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
|
||||||
}
|
$membership->execute([$groupId, $userId]);
|
||||||
|
if (!$membership->fetchColumn()) {
|
||||||
|
respond(403, '你不在该群组中');
|
||||||
|
}
|
||||||
|
$nativeStatement = $imDb->prepare(
|
||||||
|
'select `_mid` as `user_id`, `_type`, `_alias`, `_create_dt` from `t_group_member`'
|
||||||
|
. ' where `_gid` = ? order by (`_type` = 2) desc, `_create_dt` asc'
|
||||||
|
);
|
||||||
|
$nativeStatement->execute([$groupId]);
|
||||||
|
$nativeMembers = $nativeStatement->fetchAll();
|
||||||
|
$nativeUsers = fetchWebUserMap($imDb, array_column($nativeMembers, 'user_id'), $userId);
|
||||||
|
return ['members' => array_map(static function (array $member) use ($nativeUsers): array {
|
||||||
|
$uid = (string)$member['user_id'];
|
||||||
|
$user = $nativeUsers[$uid] ?? ['displayName' => $uid, 'mobile' => '', 'portrait' => '', 'isContact' => false];
|
||||||
|
$alias = trim((string)($member['_alias'] ?? ''));
|
||||||
|
$type = (int)($member['_type'] ?? 0);
|
||||||
|
return [
|
||||||
|
'uid' => $uid,
|
||||||
|
'role' => $type === 2 ? 'owner' : ($type === 1 ? 'admin' : 'member'),
|
||||||
|
'alias' => $alias,
|
||||||
|
'contactAlias' => trim((string)($user['contactAlias'] ?? '')),
|
||||||
|
'friendAlias' => trim((string)($user['friendAlias'] ?? '')),
|
||||||
|
'remarkAlias' => trim((string)($user['remarkAlias'] ?? '')),
|
||||||
|
'isContact' => (bool)($user['isContact'] ?? false),
|
||||||
|
'displayName' => trim((string)($user['contactAlias'] ?? '')) ?: ($alias ?: (string)$user['displayName']),
|
||||||
|
'rawDisplayName' => (string)$user['displayName'],
|
||||||
|
'groupDisplayName' => $alias,
|
||||||
|
'mobile' => (string)($user['mobile'] ?? ''),
|
||||||
|
'portrait' => (string)($user['portrait'] ?? ''),
|
||||||
|
];
|
||||||
|
}, $nativeMembers)];
|
||||||
|
}
|
||||||
|
|
||||||
$statement = $appDb->prepare(
|
$statement = $appDb->prepare(
|
||||||
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
|
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
|
||||||
|
|||||||
Vendored
+71
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
const WEB_MEDIA_STREAM_ROOT = '/opt/im-server/media';
|
||||||
|
|
||||||
|
function failMediaStream(int $status, string $message): never
|
||||||
|
{
|
||||||
|
http_response_code($status);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
echo $message;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = trim((string)($_GET['url'] ?? ''));
|
||||||
|
if ($url === '') {
|
||||||
|
failMediaStream(400, '缺少视频地址');
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = rawurldecode((string)(parse_url($url, PHP_URL_PATH) ?: ''));
|
||||||
|
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
|
||||||
|
failMediaStream(400, '视频地址不合法');
|
||||||
|
}
|
||||||
|
|
||||||
|
$root = realpath(WEB_MEDIA_STREAM_ROOT);
|
||||||
|
$fullPath = realpath(rtrim(WEB_MEDIA_STREAM_ROOT, '/') . $path);
|
||||||
|
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
|
||||||
|
failMediaStream(404, '视频文件不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = (int)filesize($fullPath);
|
||||||
|
$mime = (string)(mime_content_type($fullPath) ?: 'video/mp4');
|
||||||
|
if (!str_starts_with($mime, 'video/')) {
|
||||||
|
$extension = strtolower((string)pathinfo($fullPath, PATHINFO_EXTENSION));
|
||||||
|
$mime = $extension === 'webm' ? 'video/webm' : ($extension === 'mov' ? 'video/quicktime' : 'video/mp4');
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = 0;
|
||||||
|
$end = max(0, $size - 1);
|
||||||
|
$range = trim((string)($_SERVER['HTTP_RANGE'] ?? ''));
|
||||||
|
if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches)) {
|
||||||
|
if ($matches[1] !== '') $start = (int)$matches[1];
|
||||||
|
if ($matches[2] !== '') $end = min($end, (int)$matches[2]);
|
||||||
|
if ($start < 0 || $start > $end || $start >= $size) {
|
||||||
|
header('Content-Range: bytes */' . $size);
|
||||||
|
failMediaStream(416, '请求范围无效');
|
||||||
|
}
|
||||||
|
http_response_code(206);
|
||||||
|
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
|
||||||
|
}
|
||||||
|
|
||||||
|
$length = $end - $start + 1;
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
header('Content-Length: ' . $length);
|
||||||
|
header('Accept-Ranges: bytes');
|
||||||
|
header('Content-Disposition: inline');
|
||||||
|
header('Cache-Control: public, max-age=86400');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
$stream = fopen($fullPath, 'rb');
|
||||||
|
if ($stream === false) failMediaStream(500, '视频文件无法读取');
|
||||||
|
fseek($stream, $start);
|
||||||
|
$remaining = $length;
|
||||||
|
while ($remaining > 0 && !feof($stream)) {
|
||||||
|
$chunk = fread($stream, min(1024 * 1024, $remaining));
|
||||||
|
if ($chunk === false || $chunk === '') break;
|
||||||
|
echo $chunk;
|
||||||
|
$remaining -= strlen($chunk);
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
fclose($stream);
|
||||||
Vendored
+22
-6
@@ -97,26 +97,41 @@ function ensureMessageEditsTable(): void
|
|||||||
|
|
||||||
function ensurePinnedMessagesTable(): void
|
function ensurePinnedMessagesTable(): void
|
||||||
{
|
{
|
||||||
imAppDb()->exec(
|
$db = imAppDb();
|
||||||
|
$db->exec(
|
||||||
'create table if not exists `t_web_pinned_messages` ('
|
'create table if not exists `t_web_pinned_messages` ('
|
||||||
. '`user_id` varchar(64) not null,'
|
. '`user_id` varchar(64) not null,'
|
||||||
. '`conversation_type` varchar(16) not null,'
|
. '`conversation_type` varchar(16) not null,'
|
||||||
. '`target_id` varchar(128) not null,'
|
. '`target_id` varchar(128) not null,'
|
||||||
. '`message_uid` varchar(32) not null,'
|
. '`message_uid` varchar(32) not null,'
|
||||||
|
. '`message_json` text null,'
|
||||||
. '`updated_at_ms` bigint not null,'
|
. '`updated_at_ms` bigint not null,'
|
||||||
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
|
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
|
||||||
. ') engine=InnoDB default charset=utf8mb4'
|
. ') engine=InnoDB default charset=utf8mb4'
|
||||||
);
|
);
|
||||||
|
try {
|
||||||
|
$db->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
|
||||||
|
} catch (Throwable) {
|
||||||
|
// The column already exists.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid): void
|
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid, array $message): void
|
||||||
{
|
{
|
||||||
ensurePinnedMessagesTable();
|
ensurePinnedMessagesTable();
|
||||||
$statement = imAppDb()->prepare(
|
$statement = imAppDb()->prepare(
|
||||||
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `updated_at_ms`)'
|
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `message_json`, `updated_at_ms`)'
|
||||||
. ' values (?, ?, ?, ?, ?) on duplicate key update `message_uid` = values(`message_uid`), `updated_at_ms` = values(`updated_at_ms`)'
|
. ' values (?, ?, ?, ?, ?, ?) on duplicate key update `message_uid` = values(`message_uid`),'
|
||||||
|
. ' `message_json` = values(`message_json`), `updated_at_ms` = values(`updated_at_ms`)'
|
||||||
);
|
);
|
||||||
$statement->execute([$userId, $conversationType, $targetId, $messageUid, nowTimestampMs()]);
|
$statement->execute([
|
||||||
|
$userId,
|
||||||
|
$conversationType,
|
||||||
|
$targetId,
|
||||||
|
$messageUid,
|
||||||
|
json_encode($message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
|
nowTimestampMs(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removePinnedMessage(string $userId, string $conversationType, string $targetId): void
|
function removePinnedMessage(string $userId, string $conversationType, string $targetId): void
|
||||||
@@ -263,7 +278,8 @@ try {
|
|||||||
if ($targetId === '') respond(400, '缺少会话ID');
|
if ($targetId === '') respond(400, '缺少会话ID');
|
||||||
$userId = resolveActionUserId($userId);
|
$userId = resolveActionUserId($userId);
|
||||||
if ($action === 'pin') {
|
if ($action === 'pin') {
|
||||||
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText);
|
$message = is_array($payload['message'] ?? null) ? $payload['message'] : [];
|
||||||
|
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText, $message);
|
||||||
} else {
|
} else {
|
||||||
removePinnedMessage($userId, $conversationType, $targetId);
|
removePinnedMessage($userId, $conversationType, $targetId);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+16
-4
@@ -597,6 +597,7 @@ function formatWebGroupMessage(
|
|||||||
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
|
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
|
||||||
'senderProfile' => [
|
'senderProfile' => [
|
||||||
'uid' => $from,
|
'uid' => $from,
|
||||||
|
'userId' => (string)($senderUser['_name'] ?? ''),
|
||||||
'displayName' => $senderName,
|
'displayName' => $senderName,
|
||||||
'rawName' => $senderRawName,
|
'rawName' => $senderRawName,
|
||||||
'contactAlias' => $senderContactAlias,
|
'contactAlias' => $senderContactAlias,
|
||||||
@@ -964,6 +965,7 @@ function formatNativeGroupMessage(
|
|||||||
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
|
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
|
||||||
'senderProfile' => [
|
'senderProfile' => [
|
||||||
'uid' => $from,
|
'uid' => $from,
|
||||||
|
'userId' => (string)($senderUser['_name'] ?? ''),
|
||||||
'displayName' => $senderName,
|
'displayName' => $senderName,
|
||||||
'rawName' => $senderRawName,
|
'rawName' => $senderRawName,
|
||||||
'contactAlias' => $senderContactAlias,
|
'contactAlias' => $senderContactAlias,
|
||||||
@@ -1064,7 +1066,7 @@ function loadNativeGroupMessages(PDO $db, string $userId, string $groupId, int $
|
|||||||
}
|
}
|
||||||
|
|
||||||
$table = imUserMessagesTable($userId);
|
$table = imUserMessagesTable($userId);
|
||||||
$scanLimit = min(max($limit * 4, $limit), 400);
|
$scanLimit = min(max($limit * 4, $limit), 5000);
|
||||||
$statement = $db->prepare(
|
$statement = $db->prepare(
|
||||||
'select `_mid`, `_dt` from `' . $table . '`'
|
'select `_mid`, `_dt` from `' . $table . '`'
|
||||||
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
|
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
|
||||||
@@ -1624,16 +1626,26 @@ function messageSyncResult(
|
|||||||
'create table if not exists `t_web_pinned_messages` ('
|
'create table if not exists `t_web_pinned_messages` ('
|
||||||
. '`user_id` varchar(64) not null, `conversation_type` varchar(16) not null,'
|
. '`user_id` varchar(64) not null, `conversation_type` varchar(16) not null,'
|
||||||
. '`target_id` varchar(128) not null, `message_uid` varchar(32) not null,'
|
. '`target_id` varchar(128) not null, `message_uid` varchar(32) not null,'
|
||||||
|
. '`message_json` text null,'
|
||||||
. '`updated_at_ms` bigint not null, primary key (`user_id`, `conversation_type`, `target_id`)'
|
. '`updated_at_ms` bigint not null, primary key (`user_id`, `conversation_type`, `target_id`)'
|
||||||
. ') engine=InnoDB default charset=utf8mb4'
|
. ') engine=InnoDB default charset=utf8mb4'
|
||||||
);
|
);
|
||||||
|
try {
|
||||||
|
imAppDb()->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
|
||||||
|
} catch (Throwable) {
|
||||||
|
// The column already exists.
|
||||||
|
}
|
||||||
$statement = imAppDb()->prepare(
|
$statement = imAppDb()->prepare(
|
||||||
'select `message_uid` from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ? limit 1'
|
'select `message_uid`, `message_json` from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ? limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$userId, $conversationType, $targetId]);
|
$statement->execute([$userId, $conversationType, $targetId]);
|
||||||
$result['pinnedMessageUid'] = (string)($statement->fetchColumn() ?: '');
|
$pinnedRow = $statement->fetch();
|
||||||
|
$result['pinnedMessageUid'] = (string)($pinnedRow['message_uid'] ?? '');
|
||||||
|
$pinnedMessage = json_decode((string)($pinnedRow['message_json'] ?? ''), true);
|
||||||
|
$result['pinnedMessage'] = is_array($pinnedMessage) ? $pinnedMessage : null;
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
$result['pinnedMessageUid'] = '';
|
$result['pinnedMessageUid'] = '';
|
||||||
|
$result['pinnedMessage'] = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1643,7 +1655,7 @@ function messageSyncResult(
|
|||||||
$payload = readJsonPayload();
|
$payload = readJsonPayload();
|
||||||
$userId = trim((string)($payload['userId'] ?? ''));
|
$userId = trim((string)($payload['userId'] ?? ''));
|
||||||
$target = trim((string)($payload['target'] ?? ''));
|
$target = trim((string)($payload['target'] ?? ''));
|
||||||
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
|
$limit = clampLimit($payload['limit'] ?? 1000, 50, 1000);
|
||||||
$markRead = payloadBool($payload['markRead'] ?? false);
|
$markRead = payloadBool($payload['markRead'] ?? false);
|
||||||
$includeConversations = payloadBool($payload['includeConversations'] ?? false);
|
$includeConversations = payloadBool($payload['includeConversations'] ?? false);
|
||||||
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
|
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
|
||||||
|
|||||||
Vendored
+112
-28
@@ -8,8 +8,9 @@ require __DIR__ . '/im_web_bootstrap.php';
|
|||||||
|
|
||||||
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
||||||
const WEB_MEDIA_PUBLIC_PORT = 18000;
|
const WEB_MEDIA_PUBLIC_PORT = 18000;
|
||||||
const WEB_MEDIA_TYPE_IMAGE = 1;
|
const WEB_MEDIA_TYPE_IMAGE = 1;
|
||||||
const WEB_MEDIA_TYPE_FILE = 4;
|
const WEB_MEDIA_TYPE_VIDEO = 3;
|
||||||
|
const WEB_MEDIA_TYPE_FILE = 4;
|
||||||
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -245,13 +246,32 @@ function storeMessageAttachment(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachmentFromRequest(string $messageType): ?array
|
function attachmentFromRequest(string $messageType): ?array
|
||||||
{
|
{
|
||||||
if (!in_array($messageType, ['image', 'file'], true)) {
|
if (!in_array($messageType, ['image', 'video', 'file'], true)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
|
$forwardUrl = trim((string)($_POST['attachmentUrl'] ?? ''));
|
||||||
|
if ($forwardUrl !== '') {
|
||||||
|
$parts = parse_url($forwardUrl);
|
||||||
|
$scheme = strtolower((string)($parts['scheme'] ?? ''));
|
||||||
|
if (!in_array($scheme, ['http', 'https'], true) || empty($parts['host']) || strlen($forwardUrl) > 2048) {
|
||||||
|
respond(400, '媒体地址不合法');
|
||||||
|
}
|
||||||
|
$mime = trim((string)($_POST['attachmentMime'] ?? 'application/octet-stream'));
|
||||||
|
$name = safeUploadName((string)($_POST['attachmentName'] ?? ($messageType === 'image' ? '图片' : ($messageType === 'video' ? '视频.mp4' : '文件'))));
|
||||||
|
return [
|
||||||
|
'type' => $messageType,
|
||||||
|
'url' => $forwardUrl,
|
||||||
|
'name' => $name,
|
||||||
|
'mime' => $mime,
|
||||||
|
'size' => max(0, (int)($_POST['attachmentSize'] ?? 0)),
|
||||||
|
'mediaType' => $messageType === 'image' ? WEB_MEDIA_TYPE_IMAGE : ($messageType === 'video' ? WEB_MEDIA_TYPE_VIDEO : WEB_MEDIA_TYPE_FILE),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
|
||||||
if (!is_array($attachmentFile)) {
|
if (!is_array($attachmentFile)) {
|
||||||
respond(400, '缺少上传文件');
|
respond(400, '缺少上传文件');
|
||||||
}
|
}
|
||||||
@@ -266,11 +286,15 @@ $sender = messageRequestValue($payload, 'sender');
|
|||||||
$target = messageRequestValue($payload, 'target');
|
$target = messageRequestValue($payload, 'target');
|
||||||
$text = messageRequestValue($payload, 'text');
|
$text = messageRequestValue($payload, 'text');
|
||||||
$messageType = messageRequestValue($payload, 'messageType', 'text');
|
$messageType = messageRequestValue($payload, 'messageType', 'text');
|
||||||
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
|
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
|
||||||
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
|
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
|
||||||
|
$mentionedUserIds = $payload['mentionedUserIds'] ?? [];
|
||||||
|
$mentionedUserIds = is_array($mentionedUserIds)
|
||||||
|
? array_values(array_unique(array_filter(array_map(static fn($value): string => trim((string)$value), $mentionedUserIds))))
|
||||||
|
: [];
|
||||||
$attachment = null;
|
$attachment = null;
|
||||||
|
|
||||||
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'file'], true))) {
|
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'video', 'file'], true))) {
|
||||||
respond(400, '缺少发送者、接收者或消息内容');
|
respond(400, '缺少发送者、接收者或消息内容');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,11 +309,69 @@ try {
|
|||||||
. ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?'
|
. ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?'
|
||||||
. ' limit 1'
|
. ' limit 1'
|
||||||
);
|
);
|
||||||
$memberStatement->execute([$target, $sender]);
|
$memberStatement->execute([$target, $sender]);
|
||||||
$group = $memberStatement->fetch();
|
$group = $memberStatement->fetch();
|
||||||
if (!$group) {
|
if (!$group) {
|
||||||
respond(403, '你不在该群组中');
|
// 手机端创建的原生群不一定存在于 Web 自建群表中,需回退检查 IM 群成员表。
|
||||||
}
|
$imDb = imDb();
|
||||||
|
$nativeGroupStatement = $imDb->prepare(
|
||||||
|
'select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1'
|
||||||
|
);
|
||||||
|
$nativeGroupStatement->execute([$target]);
|
||||||
|
$nativeMemberStatement = $imDb->prepare(
|
||||||
|
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
|
||||||
|
);
|
||||||
|
$nativeMemberStatement->execute([$target, $sender]);
|
||||||
|
$nativeMemberType = $nativeMemberStatement->fetchColumn();
|
||||||
|
if (!$nativeGroupStatement->fetchColumn() || $nativeMemberType === false) {
|
||||||
|
respond(403, '你不在该群组中');
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachment = attachmentFromRequest($messageType);
|
||||||
|
$pushContent = $text !== ''
|
||||||
|
? $text
|
||||||
|
: ($attachment
|
||||||
|
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||||
|
: $text);
|
||||||
|
$payloadBody = [
|
||||||
|
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
|
||||||
|
'searchableContent' => $pushContent,
|
||||||
|
'pushContent' => $pushContent,
|
||||||
|
'content' => $text,
|
||||||
|
'persistFlag' => 3,
|
||||||
|
];
|
||||||
|
if ($mentionedUserIds) {
|
||||||
|
$payloadBody['mentionedType'] = 1;
|
||||||
|
$payloadBody['mentionedTarget'] = $mentionedUserIds;
|
||||||
|
}
|
||||||
|
if ($attachment) {
|
||||||
|
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
||||||
|
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
||||||
|
$payloadBody['extra'] = json_encode([
|
||||||
|
'name' => (string)$attachment['name'],
|
||||||
|
'mime' => (string)$attachment['mime'],
|
||||||
|
'size' => (int)$attachment['size'],
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
$nativeResult = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||||
|
'sender' => $sender,
|
||||||
|
'conv' => ['type' => 1, 'target' => $target, 'line' => 0],
|
||||||
|
'payload' => $payloadBody,
|
||||||
|
]), '原生群消息发送失败');
|
||||||
|
$messageUid = isset($nativeResult['messageUid']) ? (string)$nativeResult['messageUid'] : '';
|
||||||
|
$timestamp = (int)($nativeResult['timestamp'] ?? webImNowMs());
|
||||||
|
if ($attachment && $messageUid !== '') {
|
||||||
|
storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $timestamp);
|
||||||
|
}
|
||||||
|
$role = (int)$nativeMemberType === 2 ? 'owner' : ((int)$nativeMemberType === 1 ? 'admin' : 'member');
|
||||||
|
respond(0, 'success', [
|
||||||
|
'messageUid' => $messageUid,
|
||||||
|
'timestamp' => $timestamp,
|
||||||
|
'senderRole' => $role,
|
||||||
|
'attachment' => $attachment,
|
||||||
|
'nativeError' => '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
$category = (string)($group['category'] ?? 'group');
|
$category = (string)($group['category'] ?? 'group');
|
||||||
$role = (string)($group['role'] ?? 'member');
|
$role = (string)($group['role'] ?? 'member');
|
||||||
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
||||||
@@ -319,15 +401,19 @@ try {
|
|||||||
$pushContent = $text !== ''
|
$pushContent = $text !== ''
|
||||||
? $text
|
? $text
|
||||||
: ($attachment
|
: ($attachment
|
||||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||||
: $text);
|
: $text);
|
||||||
$payloadBody = [
|
$payloadBody = [
|
||||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
|
||||||
'searchableContent' => $pushContent,
|
'searchableContent' => $pushContent,
|
||||||
'pushContent' => $pushContent,
|
'pushContent' => $pushContent,
|
||||||
'content' => $text,
|
'content' => $text,
|
||||||
'persistFlag' => 3,
|
'persistFlag' => 3,
|
||||||
];
|
];
|
||||||
|
if ($mentionedUserIds) {
|
||||||
|
$payloadBody['mentionedType'] = 1;
|
||||||
|
$payloadBody['mentionedTarget'] = $mentionedUserIds;
|
||||||
|
}
|
||||||
if ($attachment) {
|
if ($attachment) {
|
||||||
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
||||||
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
||||||
@@ -344,8 +430,7 @@ try {
|
|||||||
'target' => $target,
|
'target' => $target,
|
||||||
'line' => 0,
|
'line' => 0,
|
||||||
],
|
],
|
||||||
'payload' => $payloadBody,
|
'payload' => $payloadBody,
|
||||||
'userMessage' => true,
|
|
||||||
]), '原生群消息发送失败');
|
]), '原生群消息发送失败');
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
|
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
|
||||||
@@ -364,10 +449,10 @@ try {
|
|||||||
$pushContent = $text !== ''
|
$pushContent = $text !== ''
|
||||||
? $text
|
? $text
|
||||||
: ($attachment
|
: ($attachment
|
||||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||||
: $text);
|
: $text);
|
||||||
$payloadBody = [
|
$payloadBody = [
|
||||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
|
||||||
'searchableContent' => $pushContent,
|
'searchableContent' => $pushContent,
|
||||||
'pushContent' => $pushContent,
|
'pushContent' => $pushContent,
|
||||||
'content' => $text,
|
'content' => $text,
|
||||||
@@ -390,8 +475,7 @@ try {
|
|||||||
'target' => $target,
|
'target' => $target,
|
||||||
'line' => 0,
|
'line' => 0,
|
||||||
],
|
],
|
||||||
'payload' => $payloadBody,
|
'payload' => $payloadBody,
|
||||||
'userMessage' => true,
|
|
||||||
]), '发送失败');
|
]), '发送失败');
|
||||||
|
|
||||||
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
||||||
|
|||||||
@@ -2455,6 +2455,53 @@ button {
|
|||||||
background: #111;
|
background: #111;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mention-picker {
|
||||||
|
position: absolute;
|
||||||
|
left: 18px;
|
||||||
|
bottom: 112px;
|
||||||
|
z-index: 20;
|
||||||
|
width: min(320px, calc(100% - 36px));
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid #d7e0e8;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 12px 32px rgb(15 23 42 / 18%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-picker button {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-picker button:hover {
|
||||||
|
background: #eef7f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-picker img,
|
||||||
|
.mention-picker button > span {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 7px;
|
||||||
|
-o-object-fit: cover;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-picker button > span {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #dceaf3;
|
||||||
|
color: #23445c;
|
||||||
|
}
|
||||||
|
|
||||||
.red-packet-card {
|
.red-packet-card {
|
||||||
width: min(320px, 68vw);
|
width: min(320px, 68vw);
|
||||||
margin: -10px -12px -8px;
|
margin: -10px -12px -8px;
|
||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Web IM</title><link rel="icon" href="favicon.ico"><script src="config/runtime-config.production.js"></script><script defer="defer" src="js/chunk-vendors.d3ec3fbc.js"></script><script defer="defer" src="js/app.1158390e.js"></script><link href="css/app.3ff3e457.css" rel="stylesheet"></head><body><noscript>请启用 JavaScript 后使用 Web IM。</noscript><div id="app"></div></body></html>
|
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Web IM</title><link rel="icon" href="favicon.ico"><script src="config/runtime-config.production.js"></script><script defer="defer" src="js/chunk-vendors.d3ec3fbc.js"></script><script defer="defer" src="js/app.21e75967.js"></script><link href="css/app.0488f79e.css" rel="stylesheet"></head><body><noscript>请启用 JavaScript 后使用 Web IM。</noscript><div id="app"></div></body></html>
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
+1003
-476
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -425,12 +425,38 @@ function currentMuteSetting(PDO $appDb, string $groupId): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||||
{
|
{
|
||||||
$group = fetchWebGroup($appDb, $groupId);
|
$group = fetchWebGroup($appDb, $groupId);
|
||||||
if (!$group) {
|
if (!$group) {
|
||||||
respond(404, '没有找到该群组');
|
$groupStatement = $imDb->prepare('select * from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
|
||||||
}
|
$groupStatement->execute([$groupId]);
|
||||||
|
$nativeGroup = $groupStatement->fetch();
|
||||||
|
$memberStatement = $imDb->prepare('select `_type`, `_alias` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
|
||||||
|
$memberStatement->execute([$groupId, $userId]);
|
||||||
|
$nativeMember = $memberStatement->fetch();
|
||||||
|
if (!$nativeGroup || !$nativeMember) {
|
||||||
|
respond(403, '你不在该群组中');
|
||||||
|
}
|
||||||
|
$ownerStatement = $imDb->prepare('select `_mid` from `t_group_member` where `_gid` = ? and `_type` = 2 limit 1');
|
||||||
|
$ownerStatement->execute([$groupId]);
|
||||||
|
$ownerId = (string)($ownerStatement->fetchColumn() ?: '');
|
||||||
|
$users = fetchWebUserMap($imDb, array_filter([$ownerId, $userId]), $userId);
|
||||||
|
return ['group' => [
|
||||||
|
'groupId' => $groupId,
|
||||||
|
'publicId' => (string)($nativeGroup['id'] ?? ''),
|
||||||
|
'name' => (string)($nativeGroup['_name'] ?? $groupId),
|
||||||
|
'category' => 'group',
|
||||||
|
'announcement' => (string)($nativeGroup['_bulletin'] ?? ''),
|
||||||
|
'welcomeText' => '',
|
||||||
|
'ownerUid' => $ownerId,
|
||||||
|
'ownerName' => (string)($users[$ownerId]['displayName'] ?? $ownerId),
|
||||||
|
'myRole' => ((int)($nativeMember['_type'] ?? 0) === 2 ? 'owner' : ((int)($nativeMember['_type'] ?? 0) === 1 ? 'admin' : 'member')),
|
||||||
|
'myAlias' => (string)($nativeMember['_alias'] ?? ''),
|
||||||
|
'myDisplayName' => (string)($nativeMember['_alias'] ?? '') ?: (string)($users[$userId]['displayName'] ?? $userId),
|
||||||
|
'muteSetting' => ['enabled' => false, 'mode' => 'off'],
|
||||||
|
]];
|
||||||
|
}
|
||||||
$member = fetchWebGroupMember($appDb, $groupId, $userId);
|
$member = fetchWebGroupMember($appDb, $groupId, $userId);
|
||||||
if (!$member) {
|
if (!$member) {
|
||||||
respond(403, '你不在该群组中');
|
respond(403, '你不在该群组中');
|
||||||
@@ -443,8 +469,9 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
|||||||
$myAlias = (string)($member['alias'] ?? '');
|
$myAlias = (string)($member['alias'] ?? '');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'group' => [
|
'group' => [
|
||||||
'groupId' => (string)$group['group_id'],
|
'groupId' => (string)$group['group_id'],
|
||||||
|
'publicId' => (string)($group['id'] ?? ''),
|
||||||
'name' => (string)$group['name'],
|
'name' => (string)$group['name'],
|
||||||
'category' => (string)$group['category'],
|
'category' => (string)$group['category'],
|
||||||
'announcement' => (string)($group['announcement'] ?? ''),
|
'announcement' => (string)($group['announcement'] ?? ''),
|
||||||
@@ -459,11 +486,42 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||||
{
|
{
|
||||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||||
respond(403, '你不在该群组中');
|
$membership = $imDb->prepare('select 1 from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
|
||||||
}
|
$membership->execute([$groupId, $userId]);
|
||||||
|
if (!$membership->fetchColumn()) {
|
||||||
|
respond(403, '你不在该群组中');
|
||||||
|
}
|
||||||
|
$nativeStatement = $imDb->prepare(
|
||||||
|
'select `_mid` as `user_id`, `_type`, `_alias`, `_create_dt` from `t_group_member`'
|
||||||
|
. ' where `_gid` = ? order by (`_type` = 2) desc, `_create_dt` asc'
|
||||||
|
);
|
||||||
|
$nativeStatement->execute([$groupId]);
|
||||||
|
$nativeMembers = $nativeStatement->fetchAll();
|
||||||
|
$nativeUsers = fetchWebUserMap($imDb, array_column($nativeMembers, 'user_id'), $userId);
|
||||||
|
return ['members' => array_map(static function (array $member) use ($nativeUsers): array {
|
||||||
|
$uid = (string)$member['user_id'];
|
||||||
|
$user = $nativeUsers[$uid] ?? ['displayName' => $uid, 'mobile' => '', 'portrait' => '', 'isContact' => false];
|
||||||
|
$alias = trim((string)($member['_alias'] ?? ''));
|
||||||
|
$type = (int)($member['_type'] ?? 0);
|
||||||
|
return [
|
||||||
|
'uid' => $uid,
|
||||||
|
'role' => $type === 2 ? 'owner' : ($type === 1 ? 'admin' : 'member'),
|
||||||
|
'alias' => $alias,
|
||||||
|
'contactAlias' => trim((string)($user['contactAlias'] ?? '')),
|
||||||
|
'friendAlias' => trim((string)($user['friendAlias'] ?? '')),
|
||||||
|
'remarkAlias' => trim((string)($user['remarkAlias'] ?? '')),
|
||||||
|
'isContact' => (bool)($user['isContact'] ?? false),
|
||||||
|
'displayName' => trim((string)($user['contactAlias'] ?? '')) ?: ($alias ?: (string)$user['displayName']),
|
||||||
|
'rawDisplayName' => (string)$user['displayName'],
|
||||||
|
'groupDisplayName' => $alias,
|
||||||
|
'mobile' => (string)($user['mobile'] ?? ''),
|
||||||
|
'portrait' => (string)($user['portrait'] ?? ''),
|
||||||
|
];
|
||||||
|
}, $nativeMembers)];
|
||||||
|
}
|
||||||
|
|
||||||
$statement = $appDb->prepare(
|
$statement = $appDb->prepare(
|
||||||
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
|
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
|
||||||
|
|||||||
@@ -97,26 +97,41 @@ function ensureMessageEditsTable(): void
|
|||||||
|
|
||||||
function ensurePinnedMessagesTable(): void
|
function ensurePinnedMessagesTable(): void
|
||||||
{
|
{
|
||||||
imAppDb()->exec(
|
$db = imAppDb();
|
||||||
|
$db->exec(
|
||||||
'create table if not exists `t_web_pinned_messages` ('
|
'create table if not exists `t_web_pinned_messages` ('
|
||||||
. '`user_id` varchar(64) not null,'
|
. '`user_id` varchar(64) not null,'
|
||||||
. '`conversation_type` varchar(16) not null,'
|
. '`conversation_type` varchar(16) not null,'
|
||||||
. '`target_id` varchar(128) not null,'
|
. '`target_id` varchar(128) not null,'
|
||||||
. '`message_uid` varchar(32) not null,'
|
. '`message_uid` varchar(32) not null,'
|
||||||
|
. '`message_json` text null,'
|
||||||
. '`updated_at_ms` bigint not null,'
|
. '`updated_at_ms` bigint not null,'
|
||||||
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
|
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
|
||||||
. ') engine=InnoDB default charset=utf8mb4'
|
. ') engine=InnoDB default charset=utf8mb4'
|
||||||
);
|
);
|
||||||
|
try {
|
||||||
|
$db->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
|
||||||
|
} catch (Throwable) {
|
||||||
|
// The column already exists.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid): void
|
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid, array $message): void
|
||||||
{
|
{
|
||||||
ensurePinnedMessagesTable();
|
ensurePinnedMessagesTable();
|
||||||
$statement = imAppDb()->prepare(
|
$statement = imAppDb()->prepare(
|
||||||
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `updated_at_ms`)'
|
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `message_json`, `updated_at_ms`)'
|
||||||
. ' values (?, ?, ?, ?, ?) on duplicate key update `message_uid` = values(`message_uid`), `updated_at_ms` = values(`updated_at_ms`)'
|
. ' values (?, ?, ?, ?, ?, ?) on duplicate key update `message_uid` = values(`message_uid`),'
|
||||||
|
. ' `message_json` = values(`message_json`), `updated_at_ms` = values(`updated_at_ms`)'
|
||||||
);
|
);
|
||||||
$statement->execute([$userId, $conversationType, $targetId, $messageUid, nowTimestampMs()]);
|
$statement->execute([
|
||||||
|
$userId,
|
||||||
|
$conversationType,
|
||||||
|
$targetId,
|
||||||
|
$messageUid,
|
||||||
|
json_encode($message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
|
nowTimestampMs(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removePinnedMessage(string $userId, string $conversationType, string $targetId): void
|
function removePinnedMessage(string $userId, string $conversationType, string $targetId): void
|
||||||
@@ -263,7 +278,8 @@ try {
|
|||||||
if ($targetId === '') respond(400, '缺少会话ID');
|
if ($targetId === '') respond(400, '缺少会话ID');
|
||||||
$userId = resolveActionUserId($userId);
|
$userId = resolveActionUserId($userId);
|
||||||
if ($action === 'pin') {
|
if ($action === 'pin') {
|
||||||
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText);
|
$message = is_array($payload['message'] ?? null) ? $payload['message'] : [];
|
||||||
|
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText, $message);
|
||||||
} else {
|
} else {
|
||||||
removePinnedMessage($userId, $conversationType, $targetId);
|
removePinnedMessage($userId, $conversationType, $targetId);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-4
@@ -1066,7 +1066,7 @@ function loadNativeGroupMessages(PDO $db, string $userId, string $groupId, int $
|
|||||||
}
|
}
|
||||||
|
|
||||||
$table = imUserMessagesTable($userId);
|
$table = imUserMessagesTable($userId);
|
||||||
$scanLimit = min(max($limit * 4, $limit), 400);
|
$scanLimit = min(max($limit * 4, $limit), 5000);
|
||||||
$statement = $db->prepare(
|
$statement = $db->prepare(
|
||||||
'select `_mid`, `_dt` from `' . $table . '`'
|
'select `_mid`, `_dt` from `' . $table . '`'
|
||||||
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
|
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
|
||||||
@@ -1626,16 +1626,26 @@ function messageSyncResult(
|
|||||||
'create table if not exists `t_web_pinned_messages` ('
|
'create table if not exists `t_web_pinned_messages` ('
|
||||||
. '`user_id` varchar(64) not null, `conversation_type` varchar(16) not null,'
|
. '`user_id` varchar(64) not null, `conversation_type` varchar(16) not null,'
|
||||||
. '`target_id` varchar(128) not null, `message_uid` varchar(32) not null,'
|
. '`target_id` varchar(128) not null, `message_uid` varchar(32) not null,'
|
||||||
|
. '`message_json` text null,'
|
||||||
. '`updated_at_ms` bigint not null, primary key (`user_id`, `conversation_type`, `target_id`)'
|
. '`updated_at_ms` bigint not null, primary key (`user_id`, `conversation_type`, `target_id`)'
|
||||||
. ') engine=InnoDB default charset=utf8mb4'
|
. ') engine=InnoDB default charset=utf8mb4'
|
||||||
);
|
);
|
||||||
|
try {
|
||||||
|
imAppDb()->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
|
||||||
|
} catch (Throwable) {
|
||||||
|
// The column already exists.
|
||||||
|
}
|
||||||
$statement = imAppDb()->prepare(
|
$statement = imAppDb()->prepare(
|
||||||
'select `message_uid` from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ? limit 1'
|
'select `message_uid`, `message_json` from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ? limit 1'
|
||||||
);
|
);
|
||||||
$statement->execute([$userId, $conversationType, $targetId]);
|
$statement->execute([$userId, $conversationType, $targetId]);
|
||||||
$result['pinnedMessageUid'] = (string)($statement->fetchColumn() ?: '');
|
$pinnedRow = $statement->fetch();
|
||||||
|
$result['pinnedMessageUid'] = (string)($pinnedRow['message_uid'] ?? '');
|
||||||
|
$pinnedMessage = json_decode((string)($pinnedRow['message_json'] ?? ''), true);
|
||||||
|
$result['pinnedMessage'] = is_array($pinnedMessage) ? $pinnedMessage : null;
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
$result['pinnedMessageUid'] = '';
|
$result['pinnedMessageUid'] = '';
|
||||||
|
$result['pinnedMessage'] = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1645,7 +1655,7 @@ function messageSyncResult(
|
|||||||
$payload = readJsonPayload();
|
$payload = readJsonPayload();
|
||||||
$userId = trim((string)($payload['userId'] ?? ''));
|
$userId = trim((string)($payload['userId'] ?? ''));
|
||||||
$target = trim((string)($payload['target'] ?? ''));
|
$target = trim((string)($payload['target'] ?? ''));
|
||||||
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
|
$limit = clampLimit($payload['limit'] ?? 1000, 50, 1000);
|
||||||
$markRead = payloadBool($payload['markRead'] ?? false);
|
$markRead = payloadBool($payload['markRead'] ?? false);
|
||||||
$includeConversations = payloadBool($payload['includeConversations'] ?? false);
|
$includeConversations = payloadBool($payload['includeConversations'] ?? false);
|
||||||
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
|
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
|
||||||
|
|||||||
@@ -309,11 +309,69 @@ try {
|
|||||||
. ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?'
|
. ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?'
|
||||||
. ' limit 1'
|
. ' limit 1'
|
||||||
);
|
);
|
||||||
$memberStatement->execute([$target, $sender]);
|
$memberStatement->execute([$target, $sender]);
|
||||||
$group = $memberStatement->fetch();
|
$group = $memberStatement->fetch();
|
||||||
if (!$group) {
|
if (!$group) {
|
||||||
respond(403, '你不在该群组中');
|
// 手机端创建的原生群不一定存在于 Web 自建群表中,需回退检查 IM 群成员表。
|
||||||
}
|
$imDb = imDb();
|
||||||
|
$nativeGroupStatement = $imDb->prepare(
|
||||||
|
'select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1'
|
||||||
|
);
|
||||||
|
$nativeGroupStatement->execute([$target]);
|
||||||
|
$nativeMemberStatement = $imDb->prepare(
|
||||||
|
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
|
||||||
|
);
|
||||||
|
$nativeMemberStatement->execute([$target, $sender]);
|
||||||
|
$nativeMemberType = $nativeMemberStatement->fetchColumn();
|
||||||
|
if (!$nativeGroupStatement->fetchColumn() || $nativeMemberType === false) {
|
||||||
|
respond(403, '你不在该群组中');
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachment = attachmentFromRequest($messageType);
|
||||||
|
$pushContent = $text !== ''
|
||||||
|
? $text
|
||||||
|
: ($attachment
|
||||||
|
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
|
||||||
|
: $text);
|
||||||
|
$payloadBody = [
|
||||||
|
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
|
||||||
|
'searchableContent' => $pushContent,
|
||||||
|
'pushContent' => $pushContent,
|
||||||
|
'content' => $text,
|
||||||
|
'persistFlag' => 3,
|
||||||
|
];
|
||||||
|
if ($mentionedUserIds) {
|
||||||
|
$payloadBody['mentionedType'] = 1;
|
||||||
|
$payloadBody['mentionedTarget'] = $mentionedUserIds;
|
||||||
|
}
|
||||||
|
if ($attachment) {
|
||||||
|
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
|
||||||
|
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
|
||||||
|
$payloadBody['extra'] = json_encode([
|
||||||
|
'name' => (string)$attachment['name'],
|
||||||
|
'mime' => (string)$attachment['mime'],
|
||||||
|
'size' => (int)$attachment['size'],
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
$nativeResult = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||||
|
'sender' => $sender,
|
||||||
|
'conv' => ['type' => 1, 'target' => $target, 'line' => 0],
|
||||||
|
'payload' => $payloadBody,
|
||||||
|
]), '原生群消息发送失败');
|
||||||
|
$messageUid = isset($nativeResult['messageUid']) ? (string)$nativeResult['messageUid'] : '';
|
||||||
|
$timestamp = (int)($nativeResult['timestamp'] ?? webImNowMs());
|
||||||
|
if ($attachment && $messageUid !== '') {
|
||||||
|
storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $timestamp);
|
||||||
|
}
|
||||||
|
$role = (int)$nativeMemberType === 2 ? 'owner' : ((int)$nativeMemberType === 1 ? 'admin' : 'member');
|
||||||
|
respond(0, 'success', [
|
||||||
|
'messageUid' => $messageUid,
|
||||||
|
'timestamp' => $timestamp,
|
||||||
|
'senderRole' => $role,
|
||||||
|
'attachment' => $attachment,
|
||||||
|
'nativeError' => '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
$category = (string)($group['category'] ?? 'group');
|
$category = (string)($group['category'] ?? 'group');
|
||||||
$role = (string)($group['role'] ?? 'member');
|
$role = (string)($group['role'] ?? 'member');
|
||||||
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
|
||||||
@@ -372,8 +430,7 @@ try {
|
|||||||
'target' => $target,
|
'target' => $target,
|
||||||
'line' => 0,
|
'line' => 0,
|
||||||
],
|
],
|
||||||
'payload' => $payloadBody,
|
'payload' => $payloadBody,
|
||||||
'userMessage' => true,
|
|
||||||
]), '原生群消息发送失败');
|
]), '原生群消息发送失败');
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
|
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
|
||||||
@@ -418,8 +475,7 @@ try {
|
|||||||
'target' => $target,
|
'target' => $target,
|
||||||
'line' => 0,
|
'line' => 0,
|
||||||
],
|
],
|
||||||
'payload' => $payloadBody,
|
'payload' => $payloadBody,
|
||||||
'userMessage' => true,
|
|
||||||
]), '发送失败');
|
]), '发送失败');
|
||||||
|
|
||||||
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
<MessageComposer
|
<MessageComposer
|
||||||
:disabled="!activeConversation || !session.isAuthenticated || !conversations.canSendActiveConversation"
|
:disabled="!activeConversation || !session.isAuthenticated || !conversations.canSendActiveConversation"
|
||||||
:quote-message="conversations.quotedMessage"
|
:quote-message="conversations.quotedMessage"
|
||||||
:mention-users="isGroupConversation ? groupMembers : []"
|
:mention-users="isGroupConversation ? mentionCandidates : []"
|
||||||
@cancel-quote="conversations.cancelQuote"
|
@cancel-quote="conversations.cancelQuote"
|
||||||
@send="conversations.sendText"
|
@send="conversations.sendText"
|
||||||
@send-attachment="conversations.sendAttachment"
|
@send-attachment="conversations.sendAttachment"
|
||||||
@@ -295,7 +295,7 @@
|
|||||||
<dt>群名</dt>
|
<dt>群名</dt>
|
||||||
<dd>{{ groupDetail?.name || activeConversation?.title || '-' }}</dd>
|
<dd>{{ groupDetail?.name || activeConversation?.title || '-' }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div v-if="groupDisplayId">
|
||||||
<dt>群ID</dt>
|
<dt>群ID</dt>
|
||||||
<dd>{{ groupDisplayId }}</dd>
|
<dd>{{ groupDisplayId }}</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -619,14 +619,7 @@ const singleProfile = computed(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
const groupDisplayId = computed(() => {
|
const groupDisplayId = computed(() => {
|
||||||
return String(
|
return String(groupDetail.value?.publicId || '');
|
||||||
groupDetail.value?.groupId
|
|
||||||
|| activeConversation.value?.raw?.groupId
|
|
||||||
|| activeConversation.value?.raw?.target
|
|
||||||
|| activeConversation.value?.target
|
|
||||||
|| activeConversation.value?.id?.replace(/^group-/, '')
|
|
||||||
|| '-'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
const isGroupConversation = computed(() => {
|
const isGroupConversation = computed(() => {
|
||||||
return ['group', 'channel'].includes(activeConversation.value?.type)
|
return ['group', 'channel'].includes(activeConversation.value?.type)
|
||||||
@@ -637,8 +630,26 @@ const currentGroupRole = computed(() => {
|
|||||||
});
|
});
|
||||||
const isGroupOwner = computed(() => currentGroupRole.value === 'owner');
|
const isGroupOwner = computed(() => currentGroupRole.value === 'owner');
|
||||||
const canManageGroup = computed(() => ['owner', 'admin'].includes(currentGroupRole.value));
|
const canManageGroup = computed(() => ['owner', 'admin'].includes(currentGroupRole.value));
|
||||||
const adminCandidateMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
|
const adminCandidateMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
|
||||||
const transferableMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
|
const transferableMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
|
||||||
|
const mentionCandidates = computed(() => {
|
||||||
|
const members = new Map();
|
||||||
|
groupMembers.value.forEach(member => {
|
||||||
|
const uid = String(member?.uid || '').trim();
|
||||||
|
if (uid && uid !== String(session.userId || '')) members.set(uid, member);
|
||||||
|
});
|
||||||
|
conversations.activeMessages.forEach(message => {
|
||||||
|
const uid = String(message?.from || '').trim();
|
||||||
|
if (!uid || uid === 'me' || uid === session.userId || members.has(uid)) return;
|
||||||
|
members.set(uid, {
|
||||||
|
uid,
|
||||||
|
displayName: message?.senderName || uid,
|
||||||
|
portrait: message?.senderPortrait || message?.portrait || '',
|
||||||
|
role: message?.senderRole || 'member'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Array.from(members.values());
|
||||||
|
});
|
||||||
const messageDetail = computed(() => conversations.messageDetail || null);
|
const messageDetail = computed(() => conversations.messageDetail || null);
|
||||||
const messageDetailGroup = computed(() => messageDetail.value?.group || null);
|
const messageDetailGroup = computed(() => messageDetail.value?.group || null);
|
||||||
const isMessageDetailGroup = computed(() => {
|
const isMessageDetailGroup = computed(() => {
|
||||||
@@ -795,19 +806,25 @@ function closeGroupPanel() {
|
|||||||
clearMuteSaveFeedback();
|
clearMuteSaveFeedback();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadGroupDetail() {
|
async function loadGroupDetail() {
|
||||||
if (!session.userId || !activeConversation.value?.target) {
|
if (!session.userId || !activeConversation.value?.target) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
groupBusy.value = true;
|
const requestedConversationId = activeConversation.value.id;
|
||||||
try {
|
const requestedTarget = activeConversation.value.target;
|
||||||
groupDetail.value = await imClient.getGroupDetail(session.userId, activeConversation.value.target);
|
groupBusy.value = true;
|
||||||
|
try {
|
||||||
|
const detail = await imClient.getGroupDetail(session.userId, requestedTarget);
|
||||||
|
if (activeConversation.value?.id !== requestedConversationId) return null;
|
||||||
|
groupDetail.value = detail;
|
||||||
return groupDetail.value;
|
return groupDetail.value;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
conversations.setActionError(error.message || '群资料加载失败');
|
if (activeConversation.value?.id === requestedConversationId) {
|
||||||
|
conversations.setActionError(error.message || '群资料加载失败');
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
groupBusy.value = false;
|
if (activeConversation.value?.id === requestedConversationId) groupBusy.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,19 +843,26 @@ async function openGroupMembers() {
|
|||||||
await loadGroupMembers();
|
await loadGroupMembers();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadGroupMembers() {
|
async function loadGroupMembers({ silent = false } = {}) {
|
||||||
groupBusy.value = true;
|
const requestedConversationId = activeConversation.value?.id;
|
||||||
try {
|
const requestedTarget = activeConversation.value?.target;
|
||||||
groupMembers.value = await imClient.getGroupMembers(session.userId, activeConversation.value.target);
|
if (!session.userId || !requestedConversationId || !requestedTarget) return [];
|
||||||
|
groupBusy.value = true;
|
||||||
|
try {
|
||||||
|
const members = await imClient.getGroupMembers(session.userId, requestedTarget);
|
||||||
|
if (activeConversation.value?.id !== requestedConversationId) return [];
|
||||||
|
groupMembers.value = members;
|
||||||
selectedAdminIds.value = groupMembers.value
|
selectedAdminIds.value = groupMembers.value
|
||||||
.filter(member => member.role === 'admin')
|
.filter(member => member.role === 'admin')
|
||||||
.map(member => member.uid);
|
.map(member => member.uid);
|
||||||
return groupMembers.value;
|
return groupMembers.value;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
conversations.setActionError(error.message || '群员加载失败');
|
if (!silent && activeConversation.value?.id === requestedConversationId) {
|
||||||
|
conversations.setActionError(error.message || '群员加载失败');
|
||||||
|
}
|
||||||
return [];
|
return [];
|
||||||
} finally {
|
} finally {
|
||||||
groupBusy.value = false;
|
if (activeConversation.value?.id === requestedConversationId) groupBusy.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1113,7 +1137,9 @@ onBeforeUnmount(() => {
|
|||||||
watch(
|
watch(
|
||||||
() => activeConversation.value?.id,
|
() => activeConversation.value?.id,
|
||||||
async () => {
|
async () => {
|
||||||
closeGroupMenu();
|
conversations.actionError = '';
|
||||||
|
conversations.actionNotice = '';
|
||||||
|
closeGroupMenu();
|
||||||
closeSingleProfile();
|
closeSingleProfile();
|
||||||
closeGroupPanel();
|
closeGroupPanel();
|
||||||
groupDetail.value = null;
|
groupDetail.value = null;
|
||||||
@@ -1124,7 +1150,7 @@ watch(
|
|||||||
transferTargetId.value = '';
|
transferTargetId.value = '';
|
||||||
manageSection.value = '';
|
manageSection.value = '';
|
||||||
if (isGroupConversation.value) {
|
if (isGroupConversation.value) {
|
||||||
await loadGroupMembers();
|
await loadGroupMembers({ silent: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
, { immediate: true });
|
, { immediate: true });
|
||||||
|
|||||||
@@ -17,8 +17,9 @@
|
|||||||
<button type="button" title="取消引用" @click="$emit('cancel-quote')">×</button>
|
<button type="button" title="取消引用" @click="$emit('cancel-quote')">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="composer-tools">
|
<div class="composer-tools">
|
||||||
<button type="button" title="表情" :disabled="disabled" @click="toggleEmojiPicker">☺</button>
|
<button type="button" title="表情" :disabled="disabled" @click="toggleEmojiPicker">☺</button>
|
||||||
<button type="button" title="图片" :disabled="disabled" @click="chooseImage">▧</button>
|
<button type="button" title="艾特群成员" :disabled="disabled || !mentionUsers.length" @click="openMentionPicker">@</button>
|
||||||
|
<button type="button" title="图片" :disabled="disabled" @click="chooseImage">▧</button>
|
||||||
<button type="button" title="文件" :disabled="disabled" @click="chooseFile">▤</button>
|
<button type="button" title="文件" :disabled="disabled" @click="chooseFile">▤</button>
|
||||||
<input ref="imageInput" type="file" accept="image/*" multiple @change="selectAttachment($event, 'image')">
|
<input ref="imageInput" type="file" accept="image/*" multiple @change="selectAttachment($event, 'image')">
|
||||||
<input ref="fileInput" type="file" multiple @change="selectAttachment($event, 'file')">
|
<input ref="fileInput" type="file" multiple @change="selectAttachment($event, 'file')">
|
||||||
@@ -123,12 +124,27 @@ function mentionName(member) {
|
|||||||
function handleMentionInput(event) {
|
function handleMentionInput(event) {
|
||||||
const cursor = event?.target?.selectionStart ?? text.value.length;
|
const cursor = event?.target?.selectionStart ?? text.value.length;
|
||||||
const beforeCursor = text.value.slice(0, cursor);
|
const beforeCursor = text.value.slice(0, cursor);
|
||||||
const match = beforeCursor.match(/(?:^|\s)@([^\s@]*)$/);
|
const match = beforeCursor.match(/@([^\s@]*)$/);
|
||||||
mentionOpen.value = Boolean(match && props.mentionUsers.length);
|
mentionOpen.value = Boolean(match && props.mentionUsers.length);
|
||||||
mentionQuery.value = match?.[1] || '';
|
mentionQuery.value = match?.[1] || '';
|
||||||
mentionStart.value = match ? cursor - match[1].length - 1 : -1;
|
mentionStart.value = match ? cursor - match[1].length - 1 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openMentionPicker() {
|
||||||
|
if (!props.mentionUsers.length) return;
|
||||||
|
const element = textareaEl.value;
|
||||||
|
const start = element?.selectionStart ?? text.value.length;
|
||||||
|
const end = element?.selectionEnd ?? start;
|
||||||
|
text.value = `${text.value.slice(0, start)}@${text.value.slice(end)}`;
|
||||||
|
mentionStart.value = start;
|
||||||
|
mentionQuery.value = '';
|
||||||
|
mentionOpen.value = true;
|
||||||
|
nextTick(() => {
|
||||||
|
element?.focus();
|
||||||
|
element?.setSelectionRange(start + 1, start + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function selectMention(member) {
|
function selectMention(member) {
|
||||||
const element = textareaEl.value;
|
const element = textareaEl.value;
|
||||||
const cursor = element?.selectionStart ?? text.value.length;
|
const cursor = element?.selectionStart ?? text.value.length;
|
||||||
|
|||||||
@@ -67,12 +67,12 @@
|
|||||||
<video
|
<video
|
||||||
v-else-if="isVideoMessage(message)"
|
v-else-if="isVideoMessage(message)"
|
||||||
class="message-video"
|
class="message-video"
|
||||||
|
:src="videoDisplayUrl(message.attachment.url)"
|
||||||
controls
|
controls
|
||||||
playsinline
|
playsinline
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
|
@error="useVideoOriginalFallback($event, message.attachment.url)"
|
||||||
>
|
>
|
||||||
<source :src="videoDisplayUrl(message.attachment.url)" :type="message.attachment.mime || 'video/mp4'">
|
|
||||||
<source :src="message.attachment.url" :type="message.attachment.mime || 'video/mp4'">
|
|
||||||
当前浏览器无法播放该视频
|
当前浏览器无法播放该视频
|
||||||
</video>
|
</video>
|
||||||
<a
|
<a
|
||||||
@@ -85,11 +85,11 @@
|
|||||||
>
|
>
|
||||||
<span class="message-file-icon">▤</span>
|
<span class="message-file-icon">▤</span>
|
||||||
<span class="message-file-main">
|
<span class="message-file-main">
|
||||||
<strong>{{ message.attachment.name || '文件' }}</strong>
|
<strong>{{ displayFileName(message) }}</strong>
|
||||||
<small>{{ formatFileSize(message.attachment.size) }}</small>
|
<small>{{ formatFileSize(message.attachment.size) }}</small>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<p v-if="message.text && !isRedPacketMessage(message) && !isRedPacketReceipt(message)">{{ message.text }}</p>
|
<p v-if="message.text && !isFileMessage(message) && !isRedPacketMessage(message) && !isRedPacketReceipt(message)">{{ message.text }}</p>
|
||||||
<time>
|
<time>
|
||||||
<span v-if="isRecallNotice(message)" class="recall-mark">{{ message.recallStatus || '已撤回' }}</span>
|
<span v-if="isRecallNotice(message)" class="recall-mark">{{ message.recallStatus || '已撤回' }}</span>
|
||||||
<span>{{ formatTime(message.displayTimestamp || message.timestamp) }}</span>
|
<span>{{ formatTime(message.displayTimestamp || message.timestamp) }}</span>
|
||||||
@@ -248,7 +248,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { useContactStore } from '@/stores/contacts';
|
import { useContactStore } from '@/stores/contacts';
|
||||||
import { useImageProxyFallback, videoDisplayUrl } from '@/services/mediaProxy';
|
import { useImageProxyFallback, useVideoOriginalFallback, videoDisplayUrl } from '@/services/mediaProxy';
|
||||||
import { useAutoDismiss } from '@/composables/useAutoDismiss';
|
import { useAutoDismiss } from '@/composables/useAutoDismiss';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -463,6 +463,12 @@ function isFileMessage(message) {
|
|||||||
return message?.type === 'file' && message?.attachment?.url;
|
return message?.type === 'file' && message?.attachment?.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function displayFileName(message) {
|
||||||
|
const text = String(message?.text || '').trim();
|
||||||
|
const attachmentName = String(message?.attachment?.name || '').trim();
|
||||||
|
return text || attachmentName || '文件';
|
||||||
|
}
|
||||||
|
|
||||||
function isVideoMessage(message) {
|
function isVideoMessage(message) {
|
||||||
return (message?.type === 'video' || Number(message?.contentType || 0) === 6) && message?.attachment?.url;
|
return (message?.type === 'video' || Number(message?.contentType || 0) === 6) && message?.attachment?.url;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,7 +156,13 @@ function mergeSyncedMessages(localMessages, remoteMessages, conversationId, user
|
|||||||
if (message.status === 'sent') {
|
if (message.status === 'sent') {
|
||||||
return !hasRemoteMatch(message) && now - Number(message.timestamp || 0) < 30000;
|
return !hasRemoteMatch(message) && now - Number(message.timestamp || 0) < 30000;
|
||||||
}
|
}
|
||||||
return message.status === 'sending' || message.status === 'failed';
|
if (message.status === 'failed') {
|
||||||
|
// Failed optimistic messages are only transient feedback. Keeping them in
|
||||||
|
// localStorage forever makes an old server error reappear every time the
|
||||||
|
// conversation is opened, even after later messages send successfully.
|
||||||
|
return now - Number(message.timestamp || 0) < 3000;
|
||||||
|
}
|
||||||
|
return message.status === 'sending';
|
||||||
});
|
});
|
||||||
|
|
||||||
return [...visibleRemoteMessages, ...pendingLocalMessages].sort((a, b) => {
|
return [...visibleRemoteMessages, ...pendingLocalMessages].sort((a, b) => {
|
||||||
@@ -395,7 +401,9 @@ export class HttpImClient {
|
|||||||
displayTimestamp: message.displayTimestamp || message.timestamp,
|
displayTimestamp: message.displayTimestamp || message.timestamp,
|
||||||
conversationId: conversation.id
|
conversationId: conversation.id
|
||||||
}));
|
}));
|
||||||
|
messages.hasPinnedMessageState = Object.prototype.hasOwnProperty.call(response.data?.result || {}, 'pinnedMessageUid');
|
||||||
messages.pinnedMessageUid = String(response.data?.result?.pinnedMessageUid || '');
|
messages.pinnedMessageUid = String(response.data?.result?.pinnedMessageUid || '');
|
||||||
|
messages.pinnedMessage = response.data?.result?.pinnedMessage || null;
|
||||||
if (options.includeConversations && Array.isArray(response.data?.result?.conversations)) {
|
if (options.includeConversations && Array.isArray(response.data?.result?.conversations)) {
|
||||||
const remoteConversations = response.data.result.conversations.map(remoteConversation => ({
|
const remoteConversations = response.data.result.conversations.map(remoteConversation => ({
|
||||||
...remoteConversation,
|
...remoteConversation,
|
||||||
@@ -409,21 +417,113 @@ export class HttpImClient {
|
|||||||
const messagesByConversation = readLocalMessages(userId);
|
const messagesByConversation = readLocalMessages(userId);
|
||||||
messagesByConversation[conversation.id] = mergeSyncedMessages(messagesByConversation[conversation.id] || [], messages, conversation.id, userId);
|
messagesByConversation[conversation.id] = mergeSyncedMessages(messagesByConversation[conversation.id] || [], messages, conversation.id, userId);
|
||||||
messagesByConversation[conversation.id].pinnedMessageUid = messages.pinnedMessageUid;
|
messagesByConversation[conversation.id].pinnedMessageUid = messages.pinnedMessageUid;
|
||||||
|
messagesByConversation[conversation.id].pinnedMessage = messages.pinnedMessage;
|
||||||
|
messagesByConversation[conversation.id].hasPinnedMessageState = messages.hasPinnedMessageState;
|
||||||
writeLocalMessages(messagesByConversation, userId);
|
writeLocalMessages(messagesByConversation, userId);
|
||||||
return messagesByConversation[conversation.id];
|
return messagesByConversation[conversation.id];
|
||||||
}
|
}
|
||||||
|
|
||||||
async setPinnedMessage(conversation, messageUid = '') {
|
async setPinnedMessage(conversation, messageUid = '', message = null) {
|
||||||
|
const isGroup = ['group', 'channel'].includes(conversation?.type)
|
||||||
|
|| ['group', 'channel', 1, '1'].includes(conversation?.raw?.type);
|
||||||
|
if (isGroup) {
|
||||||
|
if (messageUid && message) {
|
||||||
|
return this.addGroupPinnedMessage(conversation, message);
|
||||||
|
}
|
||||||
|
const serverPinId = String(message?.serverPinId || message?.pinId || '').trim();
|
||||||
|
if (!serverPinId) {
|
||||||
|
throw new Error('缺少置顶记录ID,请刷新会话后重试');
|
||||||
|
}
|
||||||
|
return this.removeGroupPinnedMessage(serverPinId);
|
||||||
|
}
|
||||||
const action = messageUid ? 'pin' : 'unpin';
|
const action = messageUid ? 'pin' : 'unpin';
|
||||||
const response = await axios.post(appConfig.messageActionApi, {
|
const response = await axios.post(appConfig.messageActionApi, {
|
||||||
action,
|
action,
|
||||||
userId: this.currentUser?.userId || '',
|
userId: this.currentUser?.userId || '',
|
||||||
messageUid: messageUid || '1',
|
messageUid: messageUid || '1',
|
||||||
target: conversation.target || conversation.raw?.target || '',
|
target: conversation.target || conversation.raw?.target || '',
|
||||||
conversationType: ['group', 'channel'].includes(conversation.type) ? 'group' : 'single'
|
conversationType: ['group', 'channel'].includes(conversation.type) ? 'group' : 'single',
|
||||||
|
message: message ? {
|
||||||
|
id: String(message.messageUid || message.id || messageUid),
|
||||||
|
messageUid: String(message.messageUid || message.id || messageUid),
|
||||||
|
text: String(message.text || ''),
|
||||||
|
type: message.type || 'text',
|
||||||
|
attachment: message.attachment || null,
|
||||||
|
senderName: message.senderName || message.from || '',
|
||||||
|
timestamp: Number(message.displayTimestamp || message.timestamp || Date.now())
|
||||||
|
} : null
|
||||||
}, { withCredentials: false });
|
}, { withCredentials: false });
|
||||||
if (response.data?.code !== 0) throw new Error(response.data?.message || '置顶失败');
|
if (response.data?.code !== 0) throw new Error(response.data?.message || '置顶失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchGroupPinnedMessages(conversation) {
|
||||||
|
const groupId = String(conversation?.target || conversation?.raw?.target || '').trim();
|
||||||
|
if (!groupId) return [];
|
||||||
|
const url = `${appConfig.appServer}/groupTopMessage/gets`;
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
params: { groupId },
|
||||||
|
headers: this.getAppAuthHeaders(url),
|
||||||
|
withCredentials: false
|
||||||
|
});
|
||||||
|
const payload = response.data || {};
|
||||||
|
if (payload.code !== undefined && Number(payload.code) !== 0) {
|
||||||
|
throw new Error(payload.message || '置顶消息加载失败');
|
||||||
|
}
|
||||||
|
const result = payload.result ?? payload.data ?? payload;
|
||||||
|
return Array.isArray(result) ? result : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async addGroupPinnedMessage(conversation, message) {
|
||||||
|
const groupId = String(conversation?.target || conversation?.raw?.target || '').trim();
|
||||||
|
const messageId = String(message?.messageId || message?.messageUid || message?.id || '').trim();
|
||||||
|
if (!groupId || !messageId) throw new Error('缺少群ID或消息ID');
|
||||||
|
const searchableContent = String(message?.text || '').trim()
|
||||||
|
|| (message?.type === 'image' ? '[图片]' : message?.type === 'video' ? '[视频]' : '[消息]');
|
||||||
|
const rawConversationType = Number(conversation?.raw?.type);
|
||||||
|
const conversationType = Number.isFinite(rawConversationType) && rawConversationType > 0
|
||||||
|
? rawConversationType
|
||||||
|
: 1;
|
||||||
|
const url = `${appConfig.appServer}/groupTopMessage/add`;
|
||||||
|
const response = await axios.post(url, {
|
||||||
|
groupId,
|
||||||
|
userId: message?.from || this.currentUser?.userId || '',
|
||||||
|
messageId,
|
||||||
|
messageType: conversationType,
|
||||||
|
messageText: JSON.stringify({
|
||||||
|
...message,
|
||||||
|
messageId,
|
||||||
|
from: message?.from || this.currentUser?.userId || '',
|
||||||
|
conversation: {
|
||||||
|
type: conversationType,
|
||||||
|
target: groupId,
|
||||||
|
line: Number(conversation?.raw?.line || 0)
|
||||||
|
},
|
||||||
|
content: { searchableContent }
|
||||||
|
})
|
||||||
|
}, {
|
||||||
|
headers: this.getAppAuthHeaders(url),
|
||||||
|
withCredentials: false
|
||||||
|
});
|
||||||
|
const payload = response.data || {};
|
||||||
|
if (payload.code !== undefined && Number(payload.code) !== 0) {
|
||||||
|
const detail = payload.message || payload.msg || payload.result?.message || '置顶失败';
|
||||||
|
throw new Error(payload.code !== undefined ? `${detail}(错误码 ${payload.code})` : detail);
|
||||||
|
}
|
||||||
|
return payload.result ?? payload.data ?? payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeGroupPinnedMessage(pinId) {
|
||||||
|
const url = `${appConfig.appServer}/groupTopMessage/remove/?id=${encodeURIComponent(pinId)}`;
|
||||||
|
const response = await axios.post(url, {}, {
|
||||||
|
headers: this.getAppAuthHeaders(url),
|
||||||
|
withCredentials: false
|
||||||
|
});
|
||||||
|
const payload = response.data || {};
|
||||||
|
if (payload.code !== undefined && Number(payload.code) !== 0) {
|
||||||
|
throw new Error(payload.message || '取消置顶失败');
|
||||||
|
}
|
||||||
|
return payload.result ?? payload.data ?? payload;
|
||||||
|
}
|
||||||
|
|
||||||
hideMessages(conversation, messages) {
|
hideMessages(conversation, messages) {
|
||||||
const items = Array.isArray(messages) ? messages : [messages];
|
const items = Array.isArray(messages) ? messages : [messages];
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ export function videoDisplayUrl(value) {
|
|||||||
return streamUrl.toString();
|
return streamUrl.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useVideoOriginalFallback(event, value) {
|
||||||
|
const video = event?.currentTarget;
|
||||||
|
const source = String(value || '').trim();
|
||||||
|
if (!video || !source || video.dataset.originalFallback === '1') return;
|
||||||
|
video.dataset.originalFallback = '1';
|
||||||
|
video.src = source;
|
||||||
|
video.load();
|
||||||
|
}
|
||||||
|
|
||||||
export function useImageProxyFallback(event, value) {
|
export function useImageProxyFallback(event, value) {
|
||||||
const image = event?.currentTarget;
|
const image = event?.currentTarget;
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
|
|||||||
+96
-11
@@ -63,6 +63,39 @@ function writePinnedMessages(messages) {
|
|||||||
localStorage.setItem(scopedStorageKey(LOCAL_PINNED_MESSAGES_KEY), JSON.stringify(messages));
|
localStorage.setItem(scopedStorageKey(LOCAL_PINNED_MESSAGES_KEY), JSON.stringify(messages));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseNativePinnedMessage(item, messages = []) {
|
||||||
|
if (!item) return null;
|
||||||
|
const messageId = String(item.messageId || item.messageUid || '').trim();
|
||||||
|
const matched = messages.find(message => [message.messageId, message.messageUid, message.id]
|
||||||
|
.filter(Boolean)
|
||||||
|
.some(value => String(value) === messageId));
|
||||||
|
let encoded = {};
|
||||||
|
try {
|
||||||
|
encoded = typeof item.messageText === 'string' ? JSON.parse(item.messageText) : (item.messageText || {});
|
||||||
|
} catch (error) {
|
||||||
|
encoded = {};
|
||||||
|
}
|
||||||
|
const text = String(
|
||||||
|
encoded?.content?.searchableContent
|
||||||
|
|| encoded?.messageContent?.searchableContent
|
||||||
|
|| encoded?.text
|
||||||
|
|| matched?.text
|
||||||
|
|| '[消息]'
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...(matched || {}),
|
||||||
|
key: messageId || `group-pin-${item.id}`,
|
||||||
|
id: messageId || `group-pin-${item.id}`,
|
||||||
|
messageUid: messageId || matched?.messageUid || '',
|
||||||
|
serverPinId: String(item.id || ''),
|
||||||
|
text,
|
||||||
|
type: matched?.type || encoded?.type || 'text',
|
||||||
|
attachment: matched?.attachment || encoded?.attachment || null,
|
||||||
|
senderName: matched?.senderName || encoded?.senderName || encoded?.from || '',
|
||||||
|
timestamp: Number(matched?.displayTimestamp || matched?.timestamp || item.updateTime || item.createTime || Date.now())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function sortConversations(conversations) {
|
function sortConversations(conversations) {
|
||||||
return [...conversations].sort((left, right) => {
|
return [...conversations].sort((left, right) => {
|
||||||
const leftPinned = Number(left.pinnedAt || left.top || 0);
|
const leftPinned = Number(left.pinnedAt || left.top || 0);
|
||||||
@@ -318,6 +351,7 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
selectedMessageIds: [],
|
selectedMessageIds: [],
|
||||||
favoriteMessages: [],
|
favoriteMessages: [],
|
||||||
pinnedMessagesByConversation: {},
|
pinnedMessagesByConversation: {},
|
||||||
|
groupPinnedSyncAt: {},
|
||||||
hiddenConversationIds: [],
|
hiddenConversationIds: [],
|
||||||
activeFavoriteKey: '',
|
activeFavoriteKey: '',
|
||||||
actionNotice: '',
|
actionNotice: '',
|
||||||
@@ -383,6 +417,7 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
this.conversations = [];
|
this.conversations = [];
|
||||||
this.favoriteMessages = readFavoriteMessages();
|
this.favoriteMessages = readFavoriteMessages();
|
||||||
this.pinnedMessagesByConversation = readPinnedMessages();
|
this.pinnedMessagesByConversation = readPinnedMessages();
|
||||||
|
this.groupPinnedSyncAt = {};
|
||||||
this.hiddenConversationIds = readHiddenConversationIds();
|
this.hiddenConversationIds = readHiddenConversationIds();
|
||||||
this.activeFavoriteKey = this.favoriteMessages[0]?.key || '';
|
this.activeFavoriteKey = this.favoriteMessages[0]?.key || '';
|
||||||
this.activeId = '';
|
this.activeId = '';
|
||||||
@@ -851,13 +886,40 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
const syncedConversationId = conversation.id;
|
const syncedConversationId = conversation.id;
|
||||||
const previousMessages = this.messagesByConversation[syncedConversationId] || [];
|
const previousMessages = this.messagesByConversation[syncedConversationId] || [];
|
||||||
try {
|
try {
|
||||||
const messages = await imClient.fetchRemoteMessages(conversation, 50, {
|
const messages = await imClient.fetchRemoteMessages(conversation, 1000, {
|
||||||
markRead,
|
markRead,
|
||||||
includeConversations: Boolean(options.includeConversations)
|
includeConversations: Boolean(options.includeConversations)
|
||||||
});
|
});
|
||||||
|
const isGroupConversation = ['group', 'channel'].includes(conversation.type)
|
||||||
|
|| ['group', 'channel', 1, '1'].includes(conversation.raw?.type);
|
||||||
|
const shouldSyncGroupPin = isGroupConversation
|
||||||
|
&& Date.now() - Number(this.groupPinnedSyncAt[syncedConversationId] || 0) >= 3000;
|
||||||
|
if (shouldSyncGroupPin) {
|
||||||
|
try {
|
||||||
|
const nativePins = await imClient.fetchGroupPinnedMessages(conversation);
|
||||||
|
this.groupPinnedSyncAt = {
|
||||||
|
...this.groupPinnedSyncAt,
|
||||||
|
[syncedConversationId]: Date.now()
|
||||||
|
};
|
||||||
|
const nextPinned = { ...this.pinnedMessagesByConversation };
|
||||||
|
if (nativePins.length) {
|
||||||
|
nextPinned[syncedConversationId] = parseNativePinnedMessage(nativePins[0], messages);
|
||||||
|
} else {
|
||||||
|
delete nextPinned[syncedConversationId];
|
||||||
|
}
|
||||||
|
this.pinnedMessagesByConversation = nextPinned;
|
||||||
|
writePinnedMessages(nextPinned);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('群置顶消息同步失败', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
const remotePinnedKey = String(messages.pinnedMessageUid || '');
|
const remotePinnedKey = String(messages.pinnedMessageUid || '');
|
||||||
if (remotePinnedKey) {
|
if (!isGroupConversation && remotePinnedKey) {
|
||||||
const pinnedMessage = messages.find(item => messageStableKey(item) === remotePinnedKey);
|
const pinnedMessage = messages.find(item => messageStableKey(item) === remotePinnedKey)
|
||||||
|
|| messages.pinnedMessage
|
||||||
|
|| (this.pinnedMessagesByConversation[syncedConversationId]?.key === remotePinnedKey
|
||||||
|
? this.pinnedMessagesByConversation[syncedConversationId]
|
||||||
|
: null);
|
||||||
this.pinnedMessagesByConversation = {
|
this.pinnedMessagesByConversation = {
|
||||||
...this.pinnedMessagesByConversation,
|
...this.pinnedMessagesByConversation,
|
||||||
[syncedConversationId]: pinnedMessage
|
[syncedConversationId]: pinnedMessage
|
||||||
@@ -1138,16 +1200,16 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
const current = this.pinnedMessagesByConversation[conversationId];
|
const current = this.pinnedMessagesByConversation[conversationId];
|
||||||
const next = { ...this.pinnedMessagesByConversation };
|
const next = { ...this.pinnedMessagesByConversation };
|
||||||
if (current && current.key === key) {
|
if (current && current.key === key) {
|
||||||
await imClient.setPinnedMessage(this.activeConversation, '').catch(error => this.setActionError(error.message || '取消置顶失败'));
|
|
||||||
delete next[conversationId];
|
delete next[conversationId];
|
||||||
|
this.pinnedMessagesByConversation = next;
|
||||||
|
writePinnedMessages(next);
|
||||||
this.setActionNotice('已取消置顶');
|
this.setActionNotice('已取消置顶');
|
||||||
} else {
|
|
||||||
try {
|
try {
|
||||||
await imClient.setPinnedMessage(this.activeConversation, key);
|
await imClient.setPinnedMessage(this.activeConversation, '', current);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setActionError(error.message || '置顶失败');
|
this.setActionError(`已在本地取消,服务器同步失败:${error.message || '取消置顶失败'}`);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
next[conversationId] = {
|
next[conversationId] = {
|
||||||
key,
|
key,
|
||||||
id: key,
|
id: key,
|
||||||
@@ -1157,18 +1219,41 @@ export const useConversationStore = defineStore('conversations', {
|
|||||||
senderName: message.senderName || message.from || '',
|
senderName: message.senderName || message.from || '',
|
||||||
timestamp: Number(message.displayTimestamp || message.timestamp || Date.now())
|
timestamp: Number(message.displayTimestamp || message.timestamp || Date.now())
|
||||||
};
|
};
|
||||||
|
this.pinnedMessagesByConversation = next;
|
||||||
|
writePinnedMessages(next);
|
||||||
this.setActionNotice('消息已置顶');
|
this.setActionNotice('消息已置顶');
|
||||||
|
try {
|
||||||
|
await imClient.setPinnedMessage(this.activeConversation, key, message);
|
||||||
|
const nativePins = await imClient.fetchGroupPinnedMessages(this.activeConversation).catch(() => []);
|
||||||
|
if (nativePins.length) {
|
||||||
|
const refreshed = parseNativePinnedMessage(nativePins[0], this.activeMessages);
|
||||||
|
this.pinnedMessagesByConversation = {
|
||||||
|
...this.pinnedMessagesByConversation,
|
||||||
|
[conversationId]: refreshed
|
||||||
|
};
|
||||||
|
writePinnedMessages(this.pinnedMessagesByConversation);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const rollback = { ...this.pinnedMessagesByConversation };
|
||||||
|
delete rollback[conversationId];
|
||||||
|
this.pinnedMessagesByConversation = rollback;
|
||||||
|
writePinnedMessages(rollback);
|
||||||
|
this.setActionError(`置顶失败:${error.message || '服务器同步失败'}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.pinnedMessagesByConversation = next;
|
|
||||||
writePinnedMessages(next);
|
|
||||||
},
|
},
|
||||||
async clearPinnedMessage() {
|
async clearPinnedMessage() {
|
||||||
await imClient.setPinnedMessage(this.activeConversation, '').catch(error => this.setActionError(error.message || '取消置顶失败'));
|
const current = this.pinnedMessagesByConversation[this.activeId] || null;
|
||||||
const next = { ...this.pinnedMessagesByConversation };
|
const next = { ...this.pinnedMessagesByConversation };
|
||||||
delete next[this.activeId];
|
delete next[this.activeId];
|
||||||
this.pinnedMessagesByConversation = next;
|
this.pinnedMessagesByConversation = next;
|
||||||
writePinnedMessages(next);
|
writePinnedMessages(next);
|
||||||
this.setActionNotice('已取消置顶');
|
this.setActionNotice('已取消置顶');
|
||||||
|
try {
|
||||||
|
await imClient.setPinnedMessage(this.activeConversation, '', current);
|
||||||
|
} catch (error) {
|
||||||
|
this.setActionError(`已在本地取消,服务器同步失败:${error.message || '取消置顶失败'}`);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setActiveFavorite(key) {
|
setActiveFavorite(key) {
|
||||||
this.activeFavoriteKey = String(key || '');
|
this.activeFavoriteKey = String(key || '');
|
||||||
|
|||||||
Reference in New Issue
Block a user