feat: improve realtime messaging and media handling
This commit is contained in:
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
CONFIG_FILE=${JC_WEB_IM_UPDATE_CONFIG:-/etc/jc-web-im-update.conf}
|
||||
STATE_DIR=/var/lib/jc-web-im-update
|
||||
LOCK_FILE=/run/lock/jc-web-im-update.lock
|
||||
|
||||
if [[ ! -r "$CONFIG_FILE" ]]; then
|
||||
echo "Missing configuration: $CONFIG_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$CONFIG_FILE"
|
||||
|
||||
: "${REPO_DIR:?REPO_DIR is required}"
|
||||
: "${WEB_ROOT:?WEB_ROOT is required}"
|
||||
: "${BRANCH:=main}"
|
||||
|
||||
case "$REPO_DIR" in
|
||||
/opt/jc-web-im-release|/opt/jc-web-im-release-*) ;;
|
||||
*) echo "Unsafe REPO_DIR: $REPO_DIR" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$WEB_ROOT" in
|
||||
/var/www/jc-web-im|/var/www/jc-web-im-*) ;;
|
||||
*) echo "Unsafe WEB_ROOT: $WEB_ROOT" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
exec 9>"$LOCK_FILE"
|
||||
flock -n 9 || {
|
||||
echo "Another update is already running"
|
||||
exit 0
|
||||
}
|
||||
|
||||
git -C "$REPO_DIR" fetch --quiet origin "$BRANCH"
|
||||
TARGET_COMMIT=$(git -C "$REPO_DIR" rev-parse "origin/$BRANCH")
|
||||
CURRENT_COMMIT=$(cat "$STATE_DIR/last_deployed" 2>/dev/null || true)
|
||||
|
||||
if [[ "$TARGET_COMMIT" == "$CURRENT_COMMIT" ]]; then
|
||||
echo "Already current: $TARGET_COMMIT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git -C "$REPO_DIR" reset --hard --quiet "$TARGET_COMMIT"
|
||||
DIST_DIR="$REPO_DIR/dist"
|
||||
[[ -f "$DIST_DIR/index.html" ]] || {
|
||||
echo "Missing dist/index.html at $TARGET_COMMIT" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
while IFS= read -r asset; do
|
||||
[[ -f "$DIST_DIR/$asset" ]] || {
|
||||
echo "Missing referenced asset: $asset" >&2
|
||||
exit 1
|
||||
}
|
||||
done < <(sed -nE 's/.*(css\/[^" ]+|js\/[^" ]+).*/\1/p' "$DIST_DIR/index.html")
|
||||
|
||||
find "$DIST_DIR/api" -type f -name '*.php' -print0 |
|
||||
xargs -0 -r -n1 php -l >/dev/null
|
||||
|
||||
BACKUP_DIR="/var/backups/jc-web-im/$(date +%Y%m%d-%H%M%S)"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
[[ -f "$WEB_ROOT/index.html" ]] && cp -a "$WEB_ROOT/index.html" "$BACKUP_DIR/"
|
||||
[[ -f "$WEB_ROOT/config/runtime-config.js" ]] && {
|
||||
mkdir -p "$BACKUP_DIR/config"
|
||||
cp -a "$WEB_ROOT/config/runtime-config.js" "$BACKUP_DIR/config/"
|
||||
}
|
||||
|
||||
mkdir -p "$WEB_ROOT"
|
||||
rsync -a --exclude='/config/runtime-config.js' "$DIST_DIR/" "$WEB_ROOT/"
|
||||
chown -R www-data:www-data "$WEB_ROOT"
|
||||
printf '%s\n' "$TARGET_COMMIT" >"$STATE_DIR/last_deployed"
|
||||
|
||||
echo "Deployed: $TARGET_COMMIT -> $WEB_ROOT"
|
||||
Vendored
+6
-10
@@ -34,6 +34,11 @@ try {
|
||||
} catch (Throwable) {
|
||||
$remarkMap = [];
|
||||
}
|
||||
try {
|
||||
$friendAliasMap = fetchImFriendAliasMap(imDb(), $userId, $friendIds);
|
||||
} catch (Throwable) {
|
||||
$friendAliasMap = [];
|
||||
}
|
||||
|
||||
$contactsByUid = [];
|
||||
foreach (array_values(array_filter($usersResult['userInfos'] ?? [], 'is_array')) as $user) {
|
||||
@@ -41,16 +46,7 @@ try {
|
||||
if ($uid === '' || isset($contactsByUid[$uid])) {
|
||||
continue;
|
||||
}
|
||||
$alias = '';
|
||||
try {
|
||||
$aliasResult = requireImSuccess(
|
||||
imAdminPost('/admin/friend/get_alias', ['operator' => $userId, 'targetId' => $uid]),
|
||||
'联系人备注加载失败'
|
||||
);
|
||||
$alias = trim((string)($aliasResult['alias'] ?? ''));
|
||||
} catch (Throwable $error) {
|
||||
$alias = '';
|
||||
}
|
||||
$alias = $friendAliasMap[$uid] ?? '';
|
||||
if ($alias === '') {
|
||||
$alias = $remarkMap[$uid] ?? '';
|
||||
}
|
||||
|
||||
Vendored
+194
-11
@@ -70,6 +70,8 @@ function webGroupConversation(array $group, string $role = 'member'): array
|
||||
'line' => 0,
|
||||
'category' => $category,
|
||||
'role' => $role,
|
||||
'nativeStatus' => (string)($group['native_status'] ?? ''),
|
||||
'nativeError' => (string)($group['native_error'] ?? ''),
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -176,6 +178,48 @@ function createNativeGroup(string $operator, string $groupId, string $name, stri
|
||||
]), '原生群组创建失败');
|
||||
}
|
||||
|
||||
function fetchNativeGroupInfo(string $groupKey): array
|
||||
{
|
||||
$payloads = [
|
||||
['group_id' => $groupKey],
|
||||
['groupId' => $groupKey],
|
||||
['target_id' => $groupKey],
|
||||
];
|
||||
$lastError = null;
|
||||
|
||||
foreach ($payloads as $payload) {
|
||||
try {
|
||||
return requireImSuccess(imAdminPost('/admin/group/get_info', $payload), '群组不存在');
|
||||
} catch (Throwable $error) {
|
||||
$lastError = $error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException($lastError ? ($lastError->getMessage() ?: '群组不存在') : '群组不存在');
|
||||
}
|
||||
|
||||
function nativeGroupInfoValue(array $native, string $key, string $fallback = ''): string
|
||||
{
|
||||
$sources = [$native];
|
||||
if (is_array($native['group_info'] ?? null)) {
|
||||
$sources[] = $native['group_info'];
|
||||
}
|
||||
if (is_array($native['group'] ?? null)) {
|
||||
$sources[] = $native['group'];
|
||||
if (is_array($native['group']['group_info'] ?? null)) {
|
||||
$sources[] = $native['group']['group_info'];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($sources as $source) {
|
||||
if (isset($source[$key]) && (string)$source[$key] !== '') {
|
||||
return (string)$source[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
function addNativeGroupMember(string $operator, string $groupId, string $userId): void
|
||||
{
|
||||
requireImSuccess(imAdminPost('/admin/group/member/add', [
|
||||
@@ -425,11 +469,134 @@ function currentMuteSetting(PDO $appDb, string $groupId): array
|
||||
];
|
||||
}
|
||||
|
||||
function nativeGroupRoleFromType(mixed $type): string
|
||||
{
|
||||
$value = (int)$type;
|
||||
if ($value === 2) {
|
||||
return 'owner';
|
||||
}
|
||||
if ($value === 1) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
return 'member';
|
||||
}
|
||||
|
||||
function fetchNativeGroupWithMember(PDO $imDb, string $groupId, string $userId): array
|
||||
{
|
||||
$statement = $imDb->prepare(
|
||||
'select g.*, m.`_type` as `member_type`, m.`_alias` as `member_alias`'
|
||||
. ' from `t_group` g'
|
||||
. ' join `t_group_member` m on m.`_gid` = g.`_gid`'
|
||||
. ' where g.`_gid` = ? and g.`_deleted` = 0 and m.`_mid` = ? limit 1'
|
||||
);
|
||||
$statement->execute([$groupId, $userId]);
|
||||
$group = $statement->fetch();
|
||||
|
||||
return is_array($group) ? $group : [];
|
||||
}
|
||||
|
||||
function ensureNativeGroupMember(PDO $imDb, string $groupId, string $userId): array
|
||||
{
|
||||
$group = fetchNativeGroupWithMember($imDb, $groupId, $userId);
|
||||
if ($group) {
|
||||
return $group;
|
||||
}
|
||||
|
||||
$exists = $imDb->prepare('select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
|
||||
$exists->execute([$groupId]);
|
||||
if ($exists->fetchColumn()) {
|
||||
respond(403, '你不在该群组中');
|
||||
}
|
||||
|
||||
respond(404, '没有找到该群组');
|
||||
}
|
||||
|
||||
function nativeGroupDetailAction(PDO $imDb, string $userId, string $groupId): array
|
||||
{
|
||||
$group = ensureNativeGroupMember($imDb, $groupId, $userId);
|
||||
$ownerId = (string)($group['_owner'] ?? '');
|
||||
$users = fetchWebUserMap($imDb, [$ownerId, $userId], $userId);
|
||||
$owner = $users[$ownerId] ?? ['uid' => $ownerId, 'displayName' => $ownerId, 'contactAlias' => ''];
|
||||
$me = $users[$userId] ?? ['uid' => $userId, 'displayName' => $userId];
|
||||
$myAlias = (string)($group['member_alias'] ?? '');
|
||||
|
||||
return [
|
||||
'group' => [
|
||||
'groupId' => (string)$group['_gid'],
|
||||
'name' => (string)($group['_name'] ?? ''),
|
||||
'category' => 'group',
|
||||
'announcement' => '',
|
||||
'welcomeText' => '',
|
||||
'ownerUid' => $ownerId,
|
||||
'ownerName' => (string)($owner['contactAlias'] ?? '') !== '' ? (string)$owner['contactAlias'] : (string)$owner['displayName'],
|
||||
'myRole' => nativeGroupRoleFromType($group['member_type'] ?? 0),
|
||||
'myAlias' => $myAlias,
|
||||
'myDisplayName' => $myAlias !== '' ? $myAlias : (string)$me['displayName'],
|
||||
'muteSetting' => [
|
||||
'mode' => 'off',
|
||||
'scheduleType' => 'once',
|
||||
'startsAtMs' => 0,
|
||||
'endsAtMs' => 0,
|
||||
'dailyStart' => '',
|
||||
'dailyEnd' => '',
|
||||
'enabled' => false,
|
||||
],
|
||||
'native' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function nativeGroupMembersAction(PDO $imDb, string $userId, string $groupId): array
|
||||
{
|
||||
ensureNativeGroupMember($imDb, $groupId, $userId);
|
||||
|
||||
$statement = $imDb->prepare(
|
||||
'select `_mid`, `_alias`, `_type`, `_create_dt` from `t_group_member`'
|
||||
. ' where `_gid` = ? order by (`_type` = 2) desc, (`_type` = 1) desc, `_create_dt` asc limit 500'
|
||||
);
|
||||
$statement->execute([$groupId]);
|
||||
$members = $statement->fetchAll();
|
||||
$users = fetchWebUserMap($imDb, array_column($members, '_mid'), $userId);
|
||||
|
||||
return [
|
||||
'members' => array_map(static function (array $member) use ($users): array {
|
||||
$uid = (string)$member['_mid'];
|
||||
$user = $users[$uid] ?? [
|
||||
'uid' => $uid,
|
||||
'displayName' => $uid,
|
||||
'mobile' => '',
|
||||
'portrait' => '',
|
||||
'contactAlias' => '',
|
||||
'friendAlias' => '',
|
||||
'remarkAlias' => '',
|
||||
'isContact' => false,
|
||||
];
|
||||
$alias = trim((string)($member['_alias'] ?? ''));
|
||||
$contactAlias = trim((string)($user['contactAlias'] ?? ''));
|
||||
return [
|
||||
'uid' => $uid,
|
||||
'role' => nativeGroupRoleFromType($member['_type'] ?? 0),
|
||||
'alias' => $alias,
|
||||
'contactAlias' => $contactAlias,
|
||||
'friendAlias' => trim((string)($user['friendAlias'] ?? '')),
|
||||
'remarkAlias' => trim((string)($user['remarkAlias'] ?? '')),
|
||||
'isContact' => (bool)($user['isContact'] ?? false),
|
||||
'displayName' => $contactAlias !== '' ? $contactAlias : ($alias !== '' ? $alias : (string)$user['displayName']),
|
||||
'rawDisplayName' => (string)$user['displayName'],
|
||||
'groupDisplayName' => $alias,
|
||||
'mobile' => (string)($user['mobile'] ?? ''),
|
||||
'portrait' => (string)($user['portrait'] ?? ''),
|
||||
];
|
||||
}, $members),
|
||||
];
|
||||
}
|
||||
|
||||
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||
{
|
||||
$group = fetchWebGroup($appDb, $groupId);
|
||||
if (!$group) {
|
||||
respond(404, '没有找到该群组');
|
||||
return nativeGroupDetailAction($imDb, $userId, $groupId);
|
||||
}
|
||||
$member = fetchWebGroupMember($appDb, $groupId, $userId);
|
||||
if (!$member) {
|
||||
@@ -462,7 +629,7 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
||||
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||
{
|
||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||
respond(403, '你不在该群组中');
|
||||
return nativeGroupMembersAction($imDb, $userId, $groupId);
|
||||
}
|
||||
|
||||
$statement = $appDb->prepare(
|
||||
@@ -512,7 +679,10 @@ function updateGroupAliasAction(PDO $appDb, string $userId, string $groupId, str
|
||||
respond(400, '本群昵称过长');
|
||||
}
|
||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||
respond(403, '你不在该群组中');
|
||||
ensureNativeGroupMember(imDb(), $groupId, $userId);
|
||||
$update = imDb()->prepare('update `t_group_member` set `_alias` = ? where `_gid` = ? and `_mid` = ?');
|
||||
$update->execute([$alias, $groupId, $userId]);
|
||||
return ['alias' => $alias];
|
||||
}
|
||||
|
||||
$now = webImNowMs();
|
||||
@@ -781,8 +951,16 @@ function joinWebGroupAction(PDO $appDb, string $userId, string $groupKey): array
|
||||
$group = fetchWebGroup($appDb, $groupKey);
|
||||
if (!$group) {
|
||||
try {
|
||||
$native = requireImSuccess(imAdminPost('/admin/group/get_info', ['groupId' => $groupKey]), '群组不存在');
|
||||
$native = fetchNativeGroupInfo($groupKey);
|
||||
$now = webImNowMs();
|
||||
$nativeGroupId = nativeGroupInfoValue(
|
||||
$native,
|
||||
'target_id',
|
||||
nativeGroupInfoValue($native, 'targetId', nativeGroupInfoValue($native, 'group_id', nativeGroupInfoValue($native, 'groupId', $groupKey)))
|
||||
);
|
||||
$nativeName = nativeGroupInfoValue($native, 'name', $nativeGroupId);
|
||||
$nativePortrait = nativeGroupInfoValue($native, 'portrait', '');
|
||||
$nativeOwner = nativeGroupInfoValue($native, 'owner', nativeGroupInfoValue($native, 'owner_uid', nativeGroupInfoValue($native, 'ownerUid', '')));
|
||||
$statement = $appDb->prepare(
|
||||
'insert into `t_web_groups`'
|
||||
. ' (`group_id`, `category`, `name`, `portrait`, `owner_uid`, `join_mode`, `announcement`,'
|
||||
@@ -791,33 +969,38 @@ function joinWebGroupAction(PDO $appDb, string $userId, string $groupKey): array
|
||||
. ' on duplicate key update `updated_at_ms` = values(`updated_at_ms`)'
|
||||
);
|
||||
$statement->execute([
|
||||
$groupKey,
|
||||
(string)($native['name'] ?? $groupKey),
|
||||
(string)($native['portrait'] ?? ''),
|
||||
(string)($native['owner'] ?? ''),
|
||||
$groupKey,
|
||||
$nativeGroupId,
|
||||
$nativeName,
|
||||
$nativePortrait,
|
||||
$nativeOwner,
|
||||
$nativeGroupId,
|
||||
$userId,
|
||||
$now,
|
||||
$now,
|
||||
]);
|
||||
$group = fetchWebGroup($appDb, $groupKey);
|
||||
$group = fetchWebGroup($appDb, $nativeGroupId);
|
||||
} catch (Throwable) {
|
||||
respond(404, '没有找到该群组');
|
||||
}
|
||||
}
|
||||
|
||||
$groupId = (string)$group['group_id'];
|
||||
$nativeStatus = (string)($group['native_status'] ?? '');
|
||||
$nativeError = (string)($group['native_error'] ?? '');
|
||||
upsertWebGroupMember($appDb, $groupId, $userId, 'member');
|
||||
try {
|
||||
addNativeGroupMember((string)($group['owner_uid'] ?: $userId), $groupId, $userId);
|
||||
} catch (Throwable $error) {
|
||||
// Web membership is authoritative for this web build; native group sync is best-effort.
|
||||
$nativeStatus = $nativeStatus === 'ok' ? 'partial' : ($nativeStatus ?: 'fallback');
|
||||
$nativeError = mb_substr($error->getMessage() ?: '加入原生群组失败', 0, 255, 'UTF-8');
|
||||
}
|
||||
|
||||
$role = fetchWebGroupRole($appDb, $groupId, $userId);
|
||||
return [
|
||||
'group' => fetchWebGroup($appDb, $groupId),
|
||||
'conversation' => webGroupConversation($group, $role),
|
||||
'nativeStatus' => $nativeStatus,
|
||||
'nativeError' => $nativeError,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Vendored
+25
@@ -355,6 +355,31 @@ function fetchWebUserRemarkMap(PDO $db, string $ownerId, array $targetIds): arra
|
||||
return $remarks;
|
||||
}
|
||||
|
||||
function fetchImFriendAliasMap(PDO $db, string $ownerId, array $friendIds): array
|
||||
{
|
||||
$friendIds = array_values(array_unique(array_filter(array_map(static function ($friendId): string {
|
||||
return trim((string)$friendId);
|
||||
}, $friendIds))));
|
||||
if ($ownerId === '' || !$friendIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$aliases = [];
|
||||
foreach (array_chunk($friendIds, 500) as $chunk) {
|
||||
$statement = $db->prepare(
|
||||
'select `_friend_uid`, `_alias` from `t_friend`'
|
||||
. ' where `_uid` = ? and `_friend_uid` in (' . webImPlaceholders($chunk) . ') and `_state` = 0'
|
||||
);
|
||||
$statement->execute(array_merge([$ownerId], $chunk));
|
||||
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$aliases[(string)$row['_friend_uid']] = trim((string)($row['_alias'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
return $aliases;
|
||||
}
|
||||
|
||||
function saveWebUserRemark(PDO $db, string $ownerId, string $targetId, string $alias): void
|
||||
{
|
||||
if ($ownerId === '' || $targetId === '') {
|
||||
|
||||
Vendored
+677
-9
@@ -132,7 +132,7 @@ function fetchMessageRows(PDO $db, array $messageIds): array
|
||||
|
||||
$messages = [];
|
||||
foreach ($idsByTable as $table => $ids) {
|
||||
$sql = 'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
|
||||
$sql = 'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_data`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
|
||||
. ' from `' . $table . '` where `_mid` in (' . placeholders($ids) . ')';
|
||||
$statement = $db->prepare($sql);
|
||||
$statement->execute($ids);
|
||||
@@ -232,12 +232,21 @@ function messagePreviewText(?array $message): string
|
||||
return '';
|
||||
}
|
||||
$attachment = is_array($message['attachment'] ?? null) ? $message['attachment'] : null;
|
||||
$contentType = (int)($message['contentType'] ?? 0);
|
||||
$messageType = (string)($message['type'] ?? '');
|
||||
$placeholderIsImage = $contentType === 3 || $messageType === 'image';
|
||||
$text = trim((string)($message['text'] ?? ''));
|
||||
if ($contentType !== 1 && $text === '[暂不支持的消息类型]') {
|
||||
$text = '';
|
||||
}
|
||||
if ($placeholderIsImage && ($text === '[图片]' || $text === '图片')) {
|
||||
$text = '';
|
||||
}
|
||||
if ($attachment) {
|
||||
$text = trim((string)($message['text'] ?? ''));
|
||||
return $text !== '' ? $text : attachmentFallbackText($attachment);
|
||||
}
|
||||
|
||||
return (string)($message['text'] ?? '');
|
||||
return $text;
|
||||
}
|
||||
|
||||
function fetchUserInfoMap(PDO $db, array $userIds, string $ownerId = ''): array
|
||||
@@ -405,6 +414,42 @@ function isWebGroupTarget(PDO $appDb, string $targetId): bool
|
||||
return (bool)$statement->fetchColumn();
|
||||
}
|
||||
|
||||
function nativeGroupRoleFromType(mixed $type): string
|
||||
{
|
||||
$value = (int)$type;
|
||||
if ($value === 2) {
|
||||
return 'owner';
|
||||
}
|
||||
if ($value === 1) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
return 'member';
|
||||
}
|
||||
|
||||
function isNativeGroupTarget(PDO $db, string $targetId): bool
|
||||
{
|
||||
if ($targetId === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$statement = $db->prepare('select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
|
||||
$statement->execute([$targetId]);
|
||||
|
||||
return (bool)$statement->fetchColumn();
|
||||
}
|
||||
|
||||
function nativeGroupRole(PDO $db, string $userId, string $groupId): string
|
||||
{
|
||||
$statement = $db->prepare(
|
||||
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
|
||||
);
|
||||
$statement->execute([$groupId, $userId]);
|
||||
$type = $statement->fetchColumn();
|
||||
|
||||
return $type === false ? '' : nativeGroupRoleFromType($type);
|
||||
}
|
||||
|
||||
function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $timestamp): void
|
||||
{
|
||||
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
|
||||
@@ -422,6 +467,34 @@ function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $time
|
||||
$statement->execute([$groupId, $userId, $timestamp, $now]);
|
||||
}
|
||||
|
||||
function markNativeGroupRead(PDO $db, string $userId, string $groupId, int $timestamp): void
|
||||
{
|
||||
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statement = $db->prepare(
|
||||
'select `id`, `_dt` from `t_read_report`'
|
||||
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
|
||||
. ' order by `_dt` desc limit 1'
|
||||
);
|
||||
$statement->execute([$userId, $groupId]);
|
||||
$row = $statement->fetch();
|
||||
|
||||
if ($row) {
|
||||
if ($timestamp > (int)$row['_dt']) {
|
||||
$update = $db->prepare('update `t_read_report` set `_dt` = ? where `id` = ?');
|
||||
$update->execute([$timestamp, (int)$row['id']]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$insert = $db->prepare(
|
||||
'insert into `t_read_report` (`_uid`, `_type`, `_line`, `_target`, `_dt`) values (?, 1, 0, ?, ?)'
|
||||
);
|
||||
$insert->execute([$userId, $groupId, $timestamp]);
|
||||
}
|
||||
|
||||
function webGroupRole(PDO $appDb, string $userId, string $groupId): string
|
||||
{
|
||||
$statement = $appDb->prepare(
|
||||
@@ -434,6 +507,50 @@ function webGroupRole(PDO $appDb, string $userId, string $groupId): string
|
||||
return is_string($role) && $role !== '' ? $role : '';
|
||||
}
|
||||
|
||||
function loadNativeGroupReadStateMap(PDO $db, string $userId, array $groupIds): array
|
||||
{
|
||||
$groupIds = array_values(array_unique(array_filter(array_map('strval', $groupIds))));
|
||||
if ($userId === '' || !$groupIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statement = $db->prepare(
|
||||
'select `_target`, max(`_dt`) as `_read_at` from `t_read_report`'
|
||||
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0'
|
||||
. ' and `_target` in (' . placeholders($groupIds) . ')'
|
||||
. ' group by `_target`'
|
||||
);
|
||||
$statement->execute(array_merge([$userId], $groupIds));
|
||||
|
||||
$readAtByGroup = [];
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$readAtByGroup[(string)$row['_target']] = (int)$row['_read_at'];
|
||||
}
|
||||
|
||||
return $readAtByGroup;
|
||||
}
|
||||
|
||||
function fetchNativeGroupMemberMap(PDO $db, string $groupId, array $userIds): array
|
||||
{
|
||||
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
|
||||
if ($groupId === '' || !$userIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statement = $db->prepare(
|
||||
'select `_mid`, `_alias`, `_type`, `_dt`, `_create_dt` from `t_group_member`'
|
||||
. ' where `_gid` = ? and `_mid` in (' . placeholders($userIds) . ')'
|
||||
);
|
||||
$statement->execute(array_merge([$groupId], $userIds));
|
||||
|
||||
$members = [];
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$members[(string)$row['_mid']] = $row;
|
||||
}
|
||||
|
||||
return $members;
|
||||
}
|
||||
|
||||
function formatWebGroupMessage(
|
||||
array $row,
|
||||
string $currentUserId,
|
||||
@@ -508,6 +625,314 @@ function formatWebGroupMessage(
|
||||
];
|
||||
}
|
||||
|
||||
function cleanupNativeMediaUrl(string $url): string
|
||||
{
|
||||
return rtrim($url, " \t\r\n\0\x0B\"'<>.,;)]}");
|
||||
}
|
||||
|
||||
function nativeMediaUrlScore(string $url): int
|
||||
{
|
||||
$path = strtolower((string)(parse_url($url, PHP_URL_PATH) ?: $url));
|
||||
$score = 0;
|
||||
if (preg_match('/\.(?:jpe?g|png|gif|webp|bmp|avif)$/i', $path)) {
|
||||
$score += 30;
|
||||
}
|
||||
if (strpos($path, 'image') !== false || strpos($path, 'media') !== false || strpos($path, 'upload') !== false) {
|
||||
$score += 5;
|
||||
}
|
||||
if (strpos($path, 'thumb') !== false || strpos($path, 'thumbnail') !== false) {
|
||||
$score -= 10;
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
|
||||
function extractNativeMediaUrlFromData($data): string
|
||||
{
|
||||
if (is_resource($data)) {
|
||||
$data = stream_get_contents($data);
|
||||
}
|
||||
if (!is_string($data) || $data === '') {
|
||||
return '';
|
||||
}
|
||||
if (!preg_match_all("~https?://[^\\s\"'<>\\x00]+~i", $data, $matches)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$urls = [];
|
||||
foreach ($matches[0] as $url) {
|
||||
$url = cleanupNativeMediaUrl((string)$url);
|
||||
if ($url !== '' && filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
$urls[$url] = $url;
|
||||
}
|
||||
}
|
||||
if (!$urls) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$urls = array_values($urls);
|
||||
usort($urls, static function (string $left, string $right): int {
|
||||
$scoreDelta = nativeMediaUrlScore($right) <=> nativeMediaUrlScore($left);
|
||||
return $scoreDelta !== 0 ? $scoreDelta : strlen($right) <=> strlen($left);
|
||||
});
|
||||
|
||||
return $urls[0] ?? '';
|
||||
}
|
||||
|
||||
function nativeAttachmentName(string $url, string $type): string
|
||||
{
|
||||
$path = (string)(parse_url($url, PHP_URL_PATH) ?: '');
|
||||
$name = trim(rawurldecode((string)basename($path)));
|
||||
if ($name !== '' && $name !== '.' && $name !== '/') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return $type === 'image' ? '图片' : '文件';
|
||||
}
|
||||
|
||||
function nativeAttachmentMime(string $url, string $type): string
|
||||
{
|
||||
if ($type !== 'image') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$path = strtolower((string)(parse_url($url, PHP_URL_PATH) ?: ''));
|
||||
if (preg_match('/\.jpe?g$/', $path)) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (preg_match('/\.png$/', $path)) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (preg_match('/\.gif$/', $path)) {
|
||||
return 'image/gif';
|
||||
}
|
||||
if (preg_match('/\.webp$/', $path)) {
|
||||
return 'image/webp';
|
||||
}
|
||||
if (preg_match('/\.bmp$/', $path)) {
|
||||
return 'image/bmp';
|
||||
}
|
||||
if (preg_match('/\.avif$/', $path)) {
|
||||
return 'image/avif';
|
||||
}
|
||||
|
||||
return 'image/*';
|
||||
}
|
||||
|
||||
function nativeAttachmentFromRow(array $row, ?array $attachment): ?array
|
||||
{
|
||||
if ($attachment !== null) {
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
$contentType = (int)($row['_content_type'] ?? 0);
|
||||
if ($contentType !== 3 && $contentType !== 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = extractNativeMediaUrlFromData($row['_data'] ?? '');
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$type = $contentType === 3 ? 'image' : 'file';
|
||||
|
||||
return [
|
||||
'type' => $type,
|
||||
'url' => $url,
|
||||
'name' => nativeAttachmentName($url, $type),
|
||||
'mime' => nativeAttachmentMime($url, $type),
|
||||
'size' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
function isNativePlaceholderText(string $text, int $contentType): bool
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '') {
|
||||
return false;
|
||||
}
|
||||
if ($contentType !== 1 && $text === '[暂不支持的消息类型]') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $contentType === 3 && ($text === '[图片]' || $text === '图片');
|
||||
}
|
||||
|
||||
function nativeDisplayText(string $searchableText, int $contentType, bool $hasAttachment, bool $isRecalled = false): string
|
||||
{
|
||||
$text = trim($searchableText);
|
||||
if (isNativePlaceholderText($text, $contentType)) {
|
||||
return '';
|
||||
}
|
||||
if ($text === '' && !$isRecalled) {
|
||||
return nativeMessageFallbackText($contentType, $hasAttachment);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
function nativeMessageFallbackText(int $contentType, bool $hasAttachment): string
|
||||
{
|
||||
if ($hasAttachment) {
|
||||
return '';
|
||||
}
|
||||
if ($contentType === 5) {
|
||||
return '[文件]';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function nativeMessageTableForDate(DateTimeImmutable $date): string
|
||||
{
|
||||
$localDate = $date->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
|
||||
$year = (int)$localDate->format('Y');
|
||||
$month = (int)$localDate->format('n') - 1;
|
||||
|
||||
return 't_messages_' . (($year % 3) * 12 + $month);
|
||||
}
|
||||
|
||||
function recentNativeMessageTables(int $monthsBack = 3): array
|
||||
{
|
||||
$monthsBack = max(1, min($monthsBack, 12));
|
||||
$cursor = (new DateTimeImmutable('now', new DateTimeZone(IM_DB_TIMEZONE)))
|
||||
->modify('first day of this month');
|
||||
$tables = [];
|
||||
|
||||
for ($index = 0; $index < $monthsBack; $index++) {
|
||||
$tables[] = nativeMessageTableForDate($cursor);
|
||||
$cursor = $cursor->modify('-1 month');
|
||||
}
|
||||
|
||||
return array_values(array_unique($tables));
|
||||
}
|
||||
|
||||
function loadNativeGroupRowsFromGlobal(PDO $db, array $groupIds, int $limit): array
|
||||
{
|
||||
$groupIds = array_values(array_unique(array_filter(array_map('strval', $groupIds))));
|
||||
if (!$groupIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$limit = min(max($limit, 50), 1000);
|
||||
$rows = [];
|
||||
foreach (recentNativeMessageTables(3) as $table) {
|
||||
try {
|
||||
$statement = $db->prepare(
|
||||
'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_data`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
|
||||
. ' from `' . $table . '`'
|
||||
. ' where `_type` = 1 and `_line` = 0'
|
||||
. ' and `_target` in (' . placeholders($groupIds) . ')'
|
||||
. ' order by `_mid` desc limit ' . $limit
|
||||
);
|
||||
$statement->execute($groupIds);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
usort($rows, static fn($left, $right) => strcmp((string)$right['_mid'], (string)$left['_mid']));
|
||||
|
||||
$uniqueRows = [];
|
||||
$seenMessageIds = [];
|
||||
foreach ($rows as $row) {
|
||||
$messageId = (string)$row['_mid'];
|
||||
if ($messageId === '' || isset($seenMessageIds[$messageId])) {
|
||||
continue;
|
||||
}
|
||||
$seenMessageIds[$messageId] = true;
|
||||
$uniqueRows[] = $row;
|
||||
if (count($uniqueRows) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $uniqueRows;
|
||||
}
|
||||
|
||||
function formatNativeGroupMessage(
|
||||
array $row,
|
||||
string $currentUserId,
|
||||
array $group,
|
||||
?array $attachment = null,
|
||||
array $users = [],
|
||||
array $members = []
|
||||
): array {
|
||||
$groupId = (string)$row['_target'];
|
||||
$from = (string)$row['_from'];
|
||||
$senderUser = $users[$from] ?? [];
|
||||
$senderMember = $members[$from] ?? [];
|
||||
$senderAlias = trim((string)($senderMember['_alias'] ?? ''));
|
||||
$senderContactAlias = trim((string)($senderUser['_alias'] ?? ''));
|
||||
$senderFriendAlias = trim((string)($senderUser['_friend_alias'] ?? ''));
|
||||
$senderRemarkAlias = trim((string)($senderUser['_remark_alias'] ?? ''));
|
||||
$senderRawName = trim((string)($senderUser['_display_name'] ?? ''))
|
||||
?: trim((string)($senderUser['_name'] ?? ''))
|
||||
?: trim((string)($senderUser['_mobile'] ?? ''))
|
||||
?: $from;
|
||||
$senderName = $senderContactAlias
|
||||
?: $senderAlias
|
||||
?: $senderRawName;
|
||||
$senderRole = nativeGroupRoleFromType($senderMember['_type'] ?? 0);
|
||||
$messageId = (string)$row['_mid'];
|
||||
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
|
||||
$contentType = (int)($row['_content_type'] ?? 0);
|
||||
$isRecalled = $contentType === 80;
|
||||
$direction = $from === $currentUserId ? 'out' : 'in';
|
||||
$attachment = nativeAttachmentFromRow($row, $attachment);
|
||||
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
|
||||
$text = nativeDisplayText((string)($row['_searchable_key'] ?? ''), $contentType, $attachment !== null, $isRecalled);
|
||||
|
||||
return [
|
||||
'id' => $messageId,
|
||||
'messageUid' => $messageId,
|
||||
'conversationId' => 'group-' . $groupId,
|
||||
'from' => $from,
|
||||
'senderRole' => $senderRole,
|
||||
'senderName' => $senderName,
|
||||
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
|
||||
'senderProfile' => [
|
||||
'uid' => $from,
|
||||
'displayName' => $senderName,
|
||||
'rawName' => $senderRawName,
|
||||
'contactAlias' => $senderContactAlias,
|
||||
'friendAlias' => $senderFriendAlias,
|
||||
'remarkAlias' => $senderRemarkAlias,
|
||||
'groupAlias' => $senderAlias,
|
||||
'isContact' => (bool)($senderUser['_is_friend'] ?? false),
|
||||
'mobile' => (string)($senderUser['_mobile'] ?? ''),
|
||||
'address' => (string)($senderUser['_address'] ?? ''),
|
||||
'portrait' => (string)($senderUser['_portrait'] ?? ''),
|
||||
'role' => $senderRole,
|
||||
],
|
||||
'to' => $groupId,
|
||||
'target' => $groupId,
|
||||
'direction' => $direction,
|
||||
'type' => $messageType,
|
||||
'contentType' => $contentType,
|
||||
'text' => attachmentDisplayText($text, $attachment),
|
||||
'attachment' => $attachment,
|
||||
'status' => $isRecalled && $direction === 'out' ? 'recalled' : 'sent',
|
||||
'deliveryStatus' => $direction === 'out' && !$isRecalled ? 'sent' : '',
|
||||
'recalled' => $isRecalled,
|
||||
'recallStatus' => $isRecalled ? '已撤回' : '',
|
||||
'editedAt' => 0,
|
||||
'timestamp' => $timestamp,
|
||||
'activityTimestamp' => $timestamp,
|
||||
'displayTimestamp' => $timestamp,
|
||||
'conversationType' => 'group',
|
||||
'raw' => [
|
||||
'native' => true,
|
||||
'groupName' => (string)($group['_name'] ?? ''),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $limit, bool $markRead): array
|
||||
{
|
||||
ensureWebImAppTables();
|
||||
@@ -557,6 +982,81 @@ function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $
|
||||
return $messages;
|
||||
}
|
||||
|
||||
function loadNativeGroupMessages(PDO $db, string $userId, string $groupId, int $limit, bool $markRead): array
|
||||
{
|
||||
$role = nativeGroupRole($db, $userId, $groupId);
|
||||
if ($role === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groupStatement = $db->prepare('select * from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
|
||||
$groupStatement->execute([$groupId]);
|
||||
$group = $groupStatement->fetch();
|
||||
if (!is_array($group)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$table = imUserMessagesTable($userId);
|
||||
$scanLimit = min(max($limit * 4, $limit), 400);
|
||||
$statement = $db->prepare(
|
||||
'select `_mid`, `_dt` from `' . $table . '`'
|
||||
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
|
||||
. ' order by `_mid` desc limit ' . $scanLimit
|
||||
);
|
||||
$statement->execute([$userId, $groupId]);
|
||||
$userRows = $statement->fetchAll();
|
||||
$messageRows = fetchMessageRows($db, array_column($userRows, '_mid'));
|
||||
$globalRows = loadNativeGroupRowsFromGlobal($db, [$groupId], $scanLimit);
|
||||
foreach ($globalRows as $row) {
|
||||
$messageId = (string)$row['_mid'];
|
||||
if ($messageId !== '') {
|
||||
$messageRows[$messageId] = $row;
|
||||
$userRows[] = [
|
||||
'_mid' => $messageId,
|
||||
'_dt' => (string)($row['_dt'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
usort($userRows, static fn($left, $right) => strcmp((string)$right['_mid'], (string)$left['_mid']));
|
||||
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($userRows, '_mid'));
|
||||
|
||||
$messages = [];
|
||||
$seenMessageIds = [];
|
||||
foreach ($userRows as $userRow) {
|
||||
$messageId = (string)$userRow['_mid'];
|
||||
if (isset($seenMessageIds[$messageId]) || !isset($messageRows[$messageId])) {
|
||||
continue;
|
||||
}
|
||||
$seenMessageIds[$messageId] = true;
|
||||
if (!shouldDisplayMessageForUser($messageRows[$messageId], $userId)) {
|
||||
continue;
|
||||
}
|
||||
$messages[] = $messageRows[$messageId];
|
||||
if (count($messages) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
$messages = array_reverse($messages);
|
||||
$senderIds = array_values(array_unique(array_map(static fn(array $row): string => (string)$row['_from'], $messages)));
|
||||
$users = fetchUserInfoMap($db, $senderIds, $userId);
|
||||
$memberMap = fetchNativeGroupMemberMap($db, $groupId, $senderIds);
|
||||
|
||||
$readTimestamp = 0;
|
||||
$formatted = [];
|
||||
foreach ($messages as $row) {
|
||||
$messageId = (string)$row['_mid'];
|
||||
$message = formatNativeGroupMessage($row, $userId, $group, $attachmentMap[$messageId] ?? null, $users, $memberMap);
|
||||
$readTimestamp = max($readTimestamp, (int)$message['activityTimestamp']);
|
||||
$formatted[] = $message;
|
||||
}
|
||||
|
||||
if ($markRead) {
|
||||
markNativeGroupRead($db, $userId, $groupId, $readTimestamp);
|
||||
}
|
||||
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): array
|
||||
{
|
||||
ensureWebImAppTables();
|
||||
@@ -619,7 +1119,7 @@ function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): arra
|
||||
? (messagePreviewText([
|
||||
'text' => attachmentDisplayText((string)$lastMessage['text'], $lastAttachment),
|
||||
'attachment' => $lastAttachment,
|
||||
]) ?: (string)$lastMessage['text'])
|
||||
]))
|
||||
: ($isChannel ? '公告群' : ($memberCount > 0 ? $memberCount . ' 位成员' : '群组'));
|
||||
$updatedAt = $lastMessage
|
||||
? max((int)$lastMessage['created_at_ms'], (int)($lastMessage['edited_at_ms'] ?? 0))
|
||||
@@ -649,6 +1149,152 @@ function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): arra
|
||||
return $conversations;
|
||||
}
|
||||
|
||||
function loadNativeGroupConversations(PDO $db, string $userId, int $limit): array
|
||||
{
|
||||
$statement = $db->prepare(
|
||||
'select g.*, m.`_type` as `member_type`, m.`_alias` as `member_alias`'
|
||||
. ' from `t_group_member` m'
|
||||
. ' join `t_group` g on g.`_gid` = m.`_gid` and g.`_deleted` = 0'
|
||||
. ' where m.`_mid` = ?'
|
||||
. ' order by greatest(g.`_dt`, m.`_dt`) desc limit ' . min(max($limit, 50), 100)
|
||||
);
|
||||
$statement->execute([$userId]);
|
||||
$groups = $statement->fetchAll();
|
||||
if (!$groups) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groupIds = array_values(array_unique(array_map(static fn(array $group): string => (string)$group['_gid'], $groups)));
|
||||
$table = imUserMessagesTable($userId);
|
||||
$scanLimit = min(max($limit * 20, 200), 1000);
|
||||
$messageStatement = $db->prepare(
|
||||
'select `_mid`, `_target`, `_dt` from `' . $table . '`'
|
||||
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0'
|
||||
. ' and `_target` in (' . placeholders($groupIds) . ')'
|
||||
. ' order by `_mid` desc limit ' . $scanLimit
|
||||
);
|
||||
$messageStatement->execute(array_merge([$userId], $groupIds));
|
||||
$scanRows = $messageStatement->fetchAll();
|
||||
$messageRows = fetchMessageRows($db, array_column($scanRows, '_mid'));
|
||||
$globalRows = loadNativeGroupRowsFromGlobal($db, $groupIds, $scanLimit);
|
||||
foreach ($globalRows as $row) {
|
||||
$messageId = (string)$row['_mid'];
|
||||
if ($messageId !== '') {
|
||||
$messageRows[$messageId] = $row;
|
||||
$scanRows[] = [
|
||||
'_mid' => $messageId,
|
||||
'_target' => (string)($row['_target'] ?? ''),
|
||||
'_dt' => (string)($row['_dt'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
usort($scanRows, static fn($left, $right) => strcmp((string)$right['_mid'], (string)$left['_mid']));
|
||||
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($scanRows, '_mid'));
|
||||
$readAtByGroup = loadNativeGroupReadStateMap($db, $userId, $groupIds);
|
||||
|
||||
$latestByGroup = [];
|
||||
$unreadByGroup = [];
|
||||
$seenMessageIds = [];
|
||||
foreach ($scanRows as $row) {
|
||||
$messageId = (string)$row['_mid'];
|
||||
if (isset($seenMessageIds[$messageId])) {
|
||||
continue;
|
||||
}
|
||||
$seenMessageIds[$messageId] = true;
|
||||
$groupId = (string)$row['_target'];
|
||||
$message = $messageRows[$messageId] ?? null;
|
||||
if ($groupId === '' || !$message || !shouldDisplayMessageForUser($message, $userId)) {
|
||||
continue;
|
||||
}
|
||||
$activityTimestamp = timestampMs((string)($message['_dt'] ?? ''), $messageId);
|
||||
if (!isset($latestByGroup[$groupId]) || $activityTimestamp > (int)($latestByGroup[$groupId]['_activity_at'] ?? 0)) {
|
||||
$row['_activity_at'] = $activityTimestamp;
|
||||
$latestByGroup[$groupId] = $row;
|
||||
}
|
||||
if ((string)$message['_from'] !== $userId && !isRecallMessage($message)) {
|
||||
if ($activityTimestamp > (int)($readAtByGroup[$groupId] ?? 0)) {
|
||||
$unreadByGroup[$groupId] = (int)($unreadByGroup[$groupId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$groupsById = [];
|
||||
foreach ($groups as $group) {
|
||||
$groupsById[(string)$group['_gid']] = $group;
|
||||
}
|
||||
|
||||
$senderIds = [];
|
||||
foreach ($latestByGroup as $row) {
|
||||
$message = $messageRows[(string)$row['_mid']] ?? null;
|
||||
if ($message) {
|
||||
$senderIds[] = (string)$message['_from'];
|
||||
}
|
||||
}
|
||||
$users = fetchUserInfoMap($db, $senderIds, $userId);
|
||||
|
||||
$conversations = [];
|
||||
foreach ($groupsById as $groupId => $group) {
|
||||
$latestRow = $latestByGroup[$groupId] ?? null;
|
||||
$messageId = $latestRow ? (string)$latestRow['_mid'] : '';
|
||||
$message = $messageId !== '' && isset($messageRows[$messageId])
|
||||
? formatNativeGroupMessage(
|
||||
$messageRows[$messageId],
|
||||
$userId,
|
||||
$group,
|
||||
$attachmentMap[$messageId] ?? null,
|
||||
$users,
|
||||
fetchNativeGroupMemberMap($db, $groupId, [(string)$messageRows[$messageId]['_from']])
|
||||
)
|
||||
: null;
|
||||
$title = (string)($group['_name'] ?? '') ?: $groupId;
|
||||
$updatedAt = $message ? (int)$message['activityTimestamp'] : (int)($group['_dt'] ?? 0);
|
||||
$conversations[] = [
|
||||
'id' => 'group-' . $groupId,
|
||||
'type' => 'group',
|
||||
'target' => $groupId,
|
||||
'title' => $title,
|
||||
'subtitle' => $message
|
||||
? (messagePreviewText($message) ?: nativeMessageFallbackText((int)($message['contentType'] ?? 0), false))
|
||||
: (((int)($group['_member_count'] ?? 0) > 0) ? (int)$group['_member_count'] . ' 位成员' : '群组'),
|
||||
'unread' => (int)($unreadByGroup[$groupId] ?? 0),
|
||||
'portrait' => (string)($group['_portrait'] ?? ''),
|
||||
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
|
||||
'updatedAt' => $updatedAt,
|
||||
'role' => nativeGroupRoleFromType($group['member_type'] ?? 0),
|
||||
'memberCount' => (int)($group['_member_count'] ?? 0),
|
||||
'raw' => [
|
||||
'type' => 'group',
|
||||
'target' => $groupId,
|
||||
'line' => 0,
|
||||
'native' => true,
|
||||
'role' => nativeGroupRoleFromType($group['member_type'] ?? 0),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $conversations;
|
||||
}
|
||||
|
||||
function mergeConversationsById(array $conversations, array $extraConversations): array
|
||||
{
|
||||
$merged = [];
|
||||
foreach (array_merge($conversations, $extraConversations) as $conversation) {
|
||||
$id = (string)($conversation['id'] ?? '');
|
||||
if ($id === '') {
|
||||
continue;
|
||||
}
|
||||
if (!isset($merged[$id])) {
|
||||
$merged[$id] = $conversation;
|
||||
continue;
|
||||
}
|
||||
if ((int)($conversation['updatedAt'] ?? 0) > (int)($merged[$id]['updatedAt'] ?? 0)) {
|
||||
$merged[$id] = array_merge($merged[$id], $conversation);
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($merged);
|
||||
}
|
||||
|
||||
function isRecallMessage(array $row): bool
|
||||
{
|
||||
return (int)($row['_content_type'] ?? 0) === 80;
|
||||
@@ -679,8 +1325,9 @@ function formatMessage(
|
||||
$text = (string)($row['_searchable_key'] ?? '');
|
||||
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
|
||||
$direction = $from === $currentUserId ? 'out' : 'in';
|
||||
$attachment = nativeAttachmentFromRow($row, $attachment);
|
||||
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
|
||||
$messageText = $text !== '' ? $text : ($isRecalled || $attachment ? '' : '[暂不支持的消息类型]');
|
||||
$messageText = nativeDisplayText($text, $contentType, $attachment !== null, $isRecalled);
|
||||
$messageText = attachmentDisplayText($messageText, $attachment);
|
||||
$editedAt = (int)($editState['editedAt'] ?? 0);
|
||||
$activityTimestamp = messageActivityTimestamp($timestamp, $editState);
|
||||
@@ -854,10 +1501,16 @@ function loadRecentConversations(PDO $db, string $userId, int $limit): array
|
||||
}
|
||||
|
||||
try {
|
||||
$conversations = array_merge($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
|
||||
} catch (Throwable) {
|
||||
$conversations = mergeConversationsById($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
|
||||
} catch (Throwable $error) {
|
||||
error_log('web group conversation sync failed: ' . $error->getMessage());
|
||||
// Keep single-chat sync available if appdata group metadata is temporarily unavailable.
|
||||
}
|
||||
try {
|
||||
$conversations = mergeConversationsById($conversations, loadNativeGroupConversations($db, $userId, $limit));
|
||||
} catch (Throwable $error) {
|
||||
error_log('native group conversation sync failed: ' . $error->getMessage());
|
||||
}
|
||||
|
||||
usort($conversations, static fn($a, $b) => $b['updatedAt'] <=> $a['updatedAt']);
|
||||
|
||||
@@ -881,12 +1534,27 @@ try {
|
||||
markUserDelivered($db, $userId, nowMs());
|
||||
|
||||
if ($target !== '') {
|
||||
$targetId = resolveUserId($db, $target);
|
||||
if ($conversationType === 'group' || isWebGroupTarget(imAppDb(), $target)) {
|
||||
if ($conversationType === 'group') {
|
||||
if (isWebGroupTarget(imAppDb(), $target)) {
|
||||
respond(0, 'success', [
|
||||
'messages' => loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
|
||||
]);
|
||||
}
|
||||
if (isNativeGroupTarget($db, $target)) {
|
||||
respond(0, 'success', [
|
||||
'messages' => loadNativeGroupMessages($db, $userId, $target, $limit, $markRead),
|
||||
]);
|
||||
}
|
||||
respond(0, 'success', [
|
||||
'messages' => [],
|
||||
]);
|
||||
}
|
||||
if (isWebGroupTarget(imAppDb(), $target)) {
|
||||
respond(0, 'success', [
|
||||
'messages' => loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
|
||||
]);
|
||||
}
|
||||
$targetId = resolveUserId($db, $target);
|
||||
respond(0, 'success', [
|
||||
'messages' => loadConversationMessages($db, $userId, $targetId, $limit, $markRead),
|
||||
]);
|
||||
|
||||
Vendored
+219
-55
@@ -6,12 +6,21 @@ header('Cache-Control: no-store');
|
||||
|
||||
require __DIR__ . '/im_web_bootstrap.php';
|
||||
|
||||
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
||||
define(
|
||||
'WEB_MEDIA_ROOT',
|
||||
webImEnv(
|
||||
'JC_WEB_IM_MEDIA_ROOT',
|
||||
is_dir('/var/www/im/public/media')
|
||||
? '/var/www/im/public/media'
|
||||
: (is_dir('/var/www/IM/public/media') ? '/var/www/IM/public/media' : '/opt/im-server/media')
|
||||
)
|
||||
);
|
||||
define('WEB_MEDIA_PUBLIC_PORT', (int)webImEnv('JC_WEB_IM_MEDIA_PORT', '80'));
|
||||
const WEB_MEDIA_TYPE_IMAGE = 1;
|
||||
const WEB_MEDIA_TYPE_FILE = 4;
|
||||
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||
const WEB_MINIO_PROJECT_CODE = '001';
|
||||
|
||||
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
|
||||
{
|
||||
@@ -72,6 +81,33 @@ function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $n
|
||||
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
|
||||
}
|
||||
|
||||
function nativeGroupRoleFromType(mixed $type): string
|
||||
{
|
||||
$value = (int)$type;
|
||||
if ($value === 2) {
|
||||
return 'owner';
|
||||
}
|
||||
if ($value === 1) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
return 'member';
|
||||
}
|
||||
|
||||
function fetchNativeGroupForSender(PDO $db, string $sender, string $groupId): array
|
||||
{
|
||||
$statement = $db->prepare(
|
||||
'select g.`_gid`, g.`_name`, g.`_mute`, g.`_deleted`, m.`_type` as `member_type`'
|
||||
. ' from `t_group` g'
|
||||
. ' join `t_group_member` m on m.`_gid` = g.`_gid`'
|
||||
. ' where g.`_gid` = ? and g.`_deleted` = 0 and m.`_mid` = ? limit 1'
|
||||
);
|
||||
$statement->execute([$groupId, $sender]);
|
||||
$group = $statement->fetch();
|
||||
|
||||
return is_array($group) ? $group : [];
|
||||
}
|
||||
|
||||
function messageRequestValue(array $payload, string $key, string $default = ''): string
|
||||
{
|
||||
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
|
||||
@@ -162,7 +198,106 @@ function mediaPublicBaseUrl(): string
|
||||
return $scheme . '://' . $host . ':' . $port;
|
||||
}
|
||||
|
||||
function saveMessageAttachmentFile(array $file, string $attachmentType): array
|
||||
function messageStorageConfig(): array
|
||||
{
|
||||
$config = ['enabled' => false, 'url' => ''];
|
||||
foreach (['t_app_system_conf', 'system_conf'] as $table) {
|
||||
try {
|
||||
$statement = imAppDb()->query(
|
||||
'select `conf_name`, `conf_value` from `' . $table . '`'
|
||||
. " where lower(`conf_name`) in ('is_minioplus', 'minio_url') and `is_valid` = 1"
|
||||
);
|
||||
$rows = $statement->fetchAll();
|
||||
if (!$rows) {
|
||||
continue;
|
||||
}
|
||||
foreach ($rows as $row) {
|
||||
$name = strtolower((string)($row['conf_name'] ?? ''));
|
||||
if ($name === 'is_minioplus') {
|
||||
$config['enabled'] = (string)($row['conf_value'] ?? '0') === '1';
|
||||
} elseif ($name === 'minio_url') {
|
||||
$config['url'] = rtrim(trim((string)($row['conf_value'] ?? '')), '/');
|
||||
}
|
||||
}
|
||||
break;
|
||||
} catch (Throwable $error) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
function saveMessageAttachmentToMinio(
|
||||
array $file,
|
||||
string $attachmentType,
|
||||
string $sender,
|
||||
string $baseUrl
|
||||
): array {
|
||||
$size = (int)($file['size'] ?? 0);
|
||||
$tmpName = (string)($file['tmp_name'] ?? '');
|
||||
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
||||
throw new RuntimeException('文件上传失败');
|
||||
}
|
||||
$mime = detectedUploadMime($tmpName);
|
||||
if ($attachmentType === 'image') {
|
||||
$mime = normalizedImageUploadMime($tmpName, $mime);
|
||||
}
|
||||
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
|
||||
$content = file_get_contents($tmpName);
|
||||
if ($content === false) {
|
||||
throw new RuntimeException('读取上传文件失败');
|
||||
}
|
||||
$requestBody = json_encode([
|
||||
'projectCode' => WEB_MINIO_PROJECT_CODE,
|
||||
'fileScene' => 'temp',
|
||||
'fileName' => $originalName,
|
||||
'contentType' => $mime,
|
||||
'base64Content' => base64_encode($content),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (!is_string($requestBody)) {
|
||||
throw new RuntimeException('生成图片上传请求失败');
|
||||
}
|
||||
|
||||
$handle = curl_init($baseUrl . '/project-storage/upload');
|
||||
curl_setopt_array($handle, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 8,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: minio-' . WEB_MINIO_PROJECT_CODE . '-' . $sender,
|
||||
],
|
||||
CURLOPT_POSTFIELDS => $requestBody,
|
||||
]);
|
||||
$responseBody = curl_exec($handle);
|
||||
$status = (int)curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($handle);
|
||||
curl_close($handle);
|
||||
$response = is_string($responseBody) ? json_decode($responseBody, true) : null;
|
||||
$data = is_array($response) && is_array($response['data'] ?? null) ? $response['data'] : [];
|
||||
$url = trim((string)($data['previewUrl'] ?? $data['downloadUrl'] ?? ''));
|
||||
if ($status < 200 || $status >= 300 || (int)($response['code'] ?? -1) !== 0 || $url === '') {
|
||||
$message = trim((string)($response['message'] ?? ''));
|
||||
throw new RuntimeException(
|
||||
$message !== ''
|
||||
? 'MinIO上传失败:' . $message
|
||||
: 'MinIO上传失败' . ($curlError !== '' ? ':' . $curlError : '')
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => $attachmentType,
|
||||
'url' => $url,
|
||||
'name' => $originalName,
|
||||
'mime' => $mime,
|
||||
'size' => $size,
|
||||
'mediaType' => $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE,
|
||||
];
|
||||
}
|
||||
|
||||
function saveMessageAttachmentFile(array $file, string $attachmentType, string $sender): array
|
||||
{
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
throw new RuntimeException('文件上传失败');
|
||||
@@ -172,10 +307,6 @@ function saveMessageAttachmentFile(array $file, string $attachmentType): array
|
||||
if ($size <= 0 || $size > $maxSize) {
|
||||
throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB');
|
||||
}
|
||||
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
|
||||
throw new RuntimeException('媒体存储目录不可写');
|
||||
}
|
||||
|
||||
$tmpName = (string)($file['tmp_name'] ?? '');
|
||||
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
||||
throw new RuntimeException('文件上传失败');
|
||||
@@ -185,6 +316,17 @@ function saveMessageAttachmentFile(array $file, string $attachmentType): array
|
||||
$mime = normalizedImageUploadMime($tmpName, $mime);
|
||||
}
|
||||
|
||||
$storage = messageStorageConfig();
|
||||
if ($storage['enabled']) {
|
||||
if ($storage['url'] === '') {
|
||||
throw new RuntimeException('MinIO已开启但未配置服务地址');
|
||||
}
|
||||
return saveMessageAttachmentToMinio($file, $attachmentType, $sender, $storage['url']);
|
||||
}
|
||||
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
|
||||
throw new RuntimeException('媒体存储目录不可写');
|
||||
}
|
||||
|
||||
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
|
||||
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
|
||||
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
|
||||
@@ -249,7 +391,7 @@ function storeMessageAttachment(
|
||||
]);
|
||||
}
|
||||
|
||||
function attachmentFromRequest(string $messageType): ?array
|
||||
function attachmentFromRequest(string $messageType, string $sender): ?array
|
||||
{
|
||||
if (!in_array($messageType, ['image', 'file'], true)) {
|
||||
return null;
|
||||
@@ -260,7 +402,61 @@ function attachmentFromRequest(string $messageType): ?array
|
||||
respond(400, '缺少上传文件');
|
||||
}
|
||||
|
||||
return saveMessageAttachmentFile($attachmentFile, $messageType);
|
||||
return saveMessageAttachmentFile($attachmentFile, $messageType, $sender);
|
||||
}
|
||||
|
||||
function messagePayloadBody(string $text, ?array $attachment): array
|
||||
{
|
||||
$pushContent = $text !== ''
|
||||
? $text
|
||||
: ($attachment
|
||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
||||
: $text);
|
||||
$payloadBody = [
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
||||
'searchableContent' => $pushContent,
|
||||
'pushContent' => $pushContent,
|
||||
'content' => $text,
|
||||
'persistFlag' => 3,
|
||||
];
|
||||
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);
|
||||
}
|
||||
|
||||
return $payloadBody;
|
||||
}
|
||||
|
||||
function sendNativeGroupMessage(string $sender, string $target, string $text, ?array $attachment): array
|
||||
{
|
||||
$result = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||
'sender' => $sender,
|
||||
'conv' => [
|
||||
'type' => 1,
|
||||
'target' => $target,
|
||||
'line' => 0,
|
||||
],
|
||||
'payload' => messagePayloadBody($text, $attachment),
|
||||
'userMessage' => true,
|
||||
]), '原生群消息发送失败');
|
||||
|
||||
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
||||
$timestamp = (int)($result['timestamp'] ?? webImNowMs());
|
||||
if ($attachment && $messageUid !== '') {
|
||||
ensureWebImAppTables();
|
||||
storeMessageAttachment(imAppDb(), $messageUid, 'group', $target, $sender, $attachment, $timestamp);
|
||||
}
|
||||
|
||||
return [
|
||||
'messageUid' => $messageUid !== '' ? $messageUid : null,
|
||||
'timestamp' => $timestamp,
|
||||
'attachment' => $attachment,
|
||||
];
|
||||
}
|
||||
|
||||
$contentTypeHeader = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
||||
@@ -292,7 +488,17 @@ try {
|
||||
$memberStatement->execute([$target, $sender]);
|
||||
$group = $memberStatement->fetch();
|
||||
if (!$group) {
|
||||
respond(403, '你不在该群组中');
|
||||
$nativeGroup = fetchNativeGroupForSender(imDb(), $sender, $target);
|
||||
if (!$nativeGroup) {
|
||||
respond(403, '你不在该群组中');
|
||||
}
|
||||
$attachment = attachmentFromRequest($messageType, $sender);
|
||||
$nativeResult = sendNativeGroupMessage($sender, $target, $text, $attachment);
|
||||
respond(0, 'success', [
|
||||
...$nativeResult,
|
||||
'senderRole' => nativeGroupRoleFromType($nativeGroup['member_type'] ?? 0),
|
||||
'native' => true,
|
||||
]);
|
||||
}
|
||||
$category = (string)($group['category'] ?? 'group');
|
||||
$role = (string)($group['role'] ?? 'member');
|
||||
@@ -304,7 +510,7 @@ try {
|
||||
if ($category !== 'channel' && isGroupMutedForSender($appDb, $target, $role, $now)) {
|
||||
respond(403, '当前群组处于禁言时段');
|
||||
}
|
||||
$attachment = attachmentFromRequest($messageType);
|
||||
$attachment = attachmentFromRequest($messageType, $sender);
|
||||
$messageUid = webImMessageUid();
|
||||
$insert = $appDb->prepare(
|
||||
'insert into `t_web_group_messages`'
|
||||
@@ -320,27 +526,6 @@ try {
|
||||
|
||||
$nativeError = '';
|
||||
try {
|
||||
$pushContent = $text !== ''
|
||||
? $text
|
||||
: ($attachment
|
||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
||||
: $text);
|
||||
$payloadBody = [
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
||||
'searchableContent' => $pushContent,
|
||||
'pushContent' => $pushContent,
|
||||
'content' => $text,
|
||||
'persistFlag' => 3,
|
||||
];
|
||||
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);
|
||||
}
|
||||
requireImSuccess(imAdminPost('/admin/message/send', [
|
||||
'sender' => $sender,
|
||||
'conv' => [
|
||||
@@ -348,7 +533,7 @@ try {
|
||||
'target' => $target,
|
||||
'line' => 0,
|
||||
],
|
||||
'payload' => $payloadBody,
|
||||
'payload' => messagePayloadBody($text, $attachment),
|
||||
'userMessage' => true,
|
||||
]), '原生群消息发送失败');
|
||||
} catch (Throwable $error) {
|
||||
@@ -364,28 +549,7 @@ try {
|
||||
]);
|
||||
}
|
||||
|
||||
$attachment = attachmentFromRequest($messageType);
|
||||
$pushContent = $text !== ''
|
||||
? $text
|
||||
: ($attachment
|
||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
||||
: $text);
|
||||
$payloadBody = [
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
||||
'searchableContent' => $pushContent,
|
||||
'pushContent' => $pushContent,
|
||||
'content' => $text,
|
||||
'persistFlag' => 3,
|
||||
];
|
||||
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);
|
||||
}
|
||||
$attachment = attachmentFromRequest($messageType, $sender);
|
||||
|
||||
$result = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||
'sender' => $sender,
|
||||
@@ -394,7 +558,7 @@ try {
|
||||
'target' => $target,
|
||||
'line' => 0,
|
||||
],
|
||||
'payload' => $payloadBody,
|
||||
'payload' => messagePayloadBody($text, $attachment),
|
||||
'userMessage' => true,
|
||||
]), '发送失败');
|
||||
|
||||
|
||||
Vendored
+5
-1
@@ -12,6 +12,10 @@
|
||||
imRoutePort: 80,
|
||||
useWss: false,
|
||||
wsPath: '/ws',
|
||||
sdkMode: 'http'
|
||||
sdkMode: 'http',
|
||||
syncIntervalMs: 15000,
|
||||
sseEnabled: true,
|
||||
sseTicketApi: '/api/sse_ticket.php',
|
||||
sseUrl: '/ws-events'
|
||||
};
|
||||
})();
|
||||
|
||||
+126
-4
@@ -1257,6 +1257,14 @@ button {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty-list-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.empty-chat {
|
||||
min-height: 0;
|
||||
padding: 24px;
|
||||
@@ -1917,6 +1925,17 @@ button {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.group-member-row .group-member-role {
|
||||
width: -moz-fit-content;
|
||||
width: fit-content;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid #b8d8d1;
|
||||
border-radius: 5px;
|
||||
background: #edf8f5;
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.group-management-dialog {
|
||||
width: min(520px, calc(100% - 32px));
|
||||
}
|
||||
@@ -1977,7 +1996,9 @@ button {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 24px;
|
||||
padding: 24px 24px 72px;
|
||||
scroll-padding-bottom: 72px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
@@ -2793,9 +2814,11 @@ button {
|
||||
}
|
||||
|
||||
.composer-tools button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
@@ -2806,6 +2829,105 @@ button {
|
||||
background: #edf2f5;
|
||||
}
|
||||
|
||||
.composer-tools button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.composer-attachment-preview {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 16px 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.composer-attachment-item {
|
||||
position: relative;
|
||||
width: 88px;
|
||||
min-width: 88px;
|
||||
height: 88px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #edf2f5;
|
||||
}
|
||||
|
||||
.composer-attachment-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
-o-object-fit: cover;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.composer-attachment-file-icon {
|
||||
color: var(--primary);
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.composer-attachment-name {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
padding: 4px 6px;
|
||||
background: rgba(18, 28, 40, 0.72);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.composer-attachment-item > button {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(18, 28, 40, 0.74);
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.composer-tool-button {
|
||||
border-color: rgba(23, 125, 114, 0.28);
|
||||
background: rgba(23, 125, 114, 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.composer-tool-button.file-tool {
|
||||
border-color: rgba(216, 78, 63, 0.28);
|
||||
background: rgba(216, 78, 63, 0.1);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.composer-tool-button:hover {
|
||||
border-color: currentColor;
|
||||
background: rgba(23, 125, 114, 0.16);
|
||||
}
|
||||
|
||||
.composer-tool-button.file-tool:hover {
|
||||
background: rgba(216, 78, 63, 0.16);
|
||||
}
|
||||
|
||||
.composer-tool-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.9;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.emoji-picker {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
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="img/5IuOieu9PF8MJtp43kD2kCkdLVjVwDp7.ico"><script src="config/runtime-config.js"></script><script defer="defer" src="js/chunk-vendors.075405dd.js"></script><script defer="defer" src="js/app.ec5edb4d.js"></script><link href="css/app.f8454e8e.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="img/5IuOieu9PF8MJtp43kD2kCkdLVjVwDp7.ico"><script src="config/runtime-config.js"></script><script defer="defer" src="js/chunk-vendors.86756863.js"></script><script defer="defer" src="js/app.5863459a.js"></script><link href="css/app.2c52dabb.css" rel="stylesheet"></head><body><noscript>请启用 JavaScript 后使用 Web IM。</noscript><div id="app"></div></body></html>
|
||||
+7253
-6818
File diff suppressed because it is too large
Load Diff
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+14968
-14763
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+3
-3
@@ -3,9 +3,9 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "../node_modules/.bin/vue-cli-service serve --host 0.0.0.0 --port 8023",
|
||||
"build": "../node_modules/.bin/vue-cli-service build",
|
||||
"lint": "../node_modules/.bin/vue-cli-service lint"
|
||||
"serve": "vue-cli-service serve --host 0.0.0.0 --port 8023",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^10.8.0",
|
||||
|
||||
+6
-10
@@ -34,6 +34,11 @@ try {
|
||||
} catch (Throwable) {
|
||||
$remarkMap = [];
|
||||
}
|
||||
try {
|
||||
$friendAliasMap = fetchImFriendAliasMap(imDb(), $userId, $friendIds);
|
||||
} catch (Throwable) {
|
||||
$friendAliasMap = [];
|
||||
}
|
||||
|
||||
$contactsByUid = [];
|
||||
foreach (array_values(array_filter($usersResult['userInfos'] ?? [], 'is_array')) as $user) {
|
||||
@@ -41,16 +46,7 @@ try {
|
||||
if ($uid === '' || isset($contactsByUid[$uid])) {
|
||||
continue;
|
||||
}
|
||||
$alias = '';
|
||||
try {
|
||||
$aliasResult = requireImSuccess(
|
||||
imAdminPost('/admin/friend/get_alias', ['operator' => $userId, 'targetId' => $uid]),
|
||||
'联系人备注加载失败'
|
||||
);
|
||||
$alias = trim((string)($aliasResult['alias'] ?? ''));
|
||||
} catch (Throwable $error) {
|
||||
$alias = '';
|
||||
}
|
||||
$alias = $friendAliasMap[$uid] ?? '';
|
||||
if ($alias === '') {
|
||||
$alias = $remarkMap[$uid] ?? '';
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ function webGroupConversation(array $group, string $role = 'member'): array
|
||||
'line' => 0,
|
||||
'category' => $category,
|
||||
'role' => $role,
|
||||
'nativeStatus' => (string)($group['native_status'] ?? ''),
|
||||
'nativeError' => (string)($group['native_error'] ?? ''),
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -176,6 +178,48 @@ function createNativeGroup(string $operator, string $groupId, string $name, stri
|
||||
]), '原生群组创建失败');
|
||||
}
|
||||
|
||||
function fetchNativeGroupInfo(string $groupKey): array
|
||||
{
|
||||
$payloads = [
|
||||
['group_id' => $groupKey],
|
||||
['groupId' => $groupKey],
|
||||
['target_id' => $groupKey],
|
||||
];
|
||||
$lastError = null;
|
||||
|
||||
foreach ($payloads as $payload) {
|
||||
try {
|
||||
return requireImSuccess(imAdminPost('/admin/group/get_info', $payload), '群组不存在');
|
||||
} catch (Throwable $error) {
|
||||
$lastError = $error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException($lastError ? ($lastError->getMessage() ?: '群组不存在') : '群组不存在');
|
||||
}
|
||||
|
||||
function nativeGroupInfoValue(array $native, string $key, string $fallback = ''): string
|
||||
{
|
||||
$sources = [$native];
|
||||
if (is_array($native['group_info'] ?? null)) {
|
||||
$sources[] = $native['group_info'];
|
||||
}
|
||||
if (is_array($native['group'] ?? null)) {
|
||||
$sources[] = $native['group'];
|
||||
if (is_array($native['group']['group_info'] ?? null)) {
|
||||
$sources[] = $native['group']['group_info'];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($sources as $source) {
|
||||
if (isset($source[$key]) && (string)$source[$key] !== '') {
|
||||
return (string)$source[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
function addNativeGroupMember(string $operator, string $groupId, string $userId): void
|
||||
{
|
||||
requireImSuccess(imAdminPost('/admin/group/member/add', [
|
||||
@@ -425,11 +469,134 @@ function currentMuteSetting(PDO $appDb, string $groupId): array
|
||||
];
|
||||
}
|
||||
|
||||
function nativeGroupRoleFromType(mixed $type): string
|
||||
{
|
||||
$value = (int)$type;
|
||||
if ($value === 2) {
|
||||
return 'owner';
|
||||
}
|
||||
if ($value === 1) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
return 'member';
|
||||
}
|
||||
|
||||
function fetchNativeGroupWithMember(PDO $imDb, string $groupId, string $userId): array
|
||||
{
|
||||
$statement = $imDb->prepare(
|
||||
'select g.*, m.`_type` as `member_type`, m.`_alias` as `member_alias`'
|
||||
. ' from `t_group` g'
|
||||
. ' join `t_group_member` m on m.`_gid` = g.`_gid`'
|
||||
. ' where g.`_gid` = ? and g.`_deleted` = 0 and m.`_mid` = ? limit 1'
|
||||
);
|
||||
$statement->execute([$groupId, $userId]);
|
||||
$group = $statement->fetch();
|
||||
|
||||
return is_array($group) ? $group : [];
|
||||
}
|
||||
|
||||
function ensureNativeGroupMember(PDO $imDb, string $groupId, string $userId): array
|
||||
{
|
||||
$group = fetchNativeGroupWithMember($imDb, $groupId, $userId);
|
||||
if ($group) {
|
||||
return $group;
|
||||
}
|
||||
|
||||
$exists = $imDb->prepare('select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
|
||||
$exists->execute([$groupId]);
|
||||
if ($exists->fetchColumn()) {
|
||||
respond(403, '你不在该群组中');
|
||||
}
|
||||
|
||||
respond(404, '没有找到该群组');
|
||||
}
|
||||
|
||||
function nativeGroupDetailAction(PDO $imDb, string $userId, string $groupId): array
|
||||
{
|
||||
$group = ensureNativeGroupMember($imDb, $groupId, $userId);
|
||||
$ownerId = (string)($group['_owner'] ?? '');
|
||||
$users = fetchWebUserMap($imDb, [$ownerId, $userId], $userId);
|
||||
$owner = $users[$ownerId] ?? ['uid' => $ownerId, 'displayName' => $ownerId, 'contactAlias' => ''];
|
||||
$me = $users[$userId] ?? ['uid' => $userId, 'displayName' => $userId];
|
||||
$myAlias = (string)($group['member_alias'] ?? '');
|
||||
|
||||
return [
|
||||
'group' => [
|
||||
'groupId' => (string)$group['_gid'],
|
||||
'name' => (string)($group['_name'] ?? ''),
|
||||
'category' => 'group',
|
||||
'announcement' => '',
|
||||
'welcomeText' => '',
|
||||
'ownerUid' => $ownerId,
|
||||
'ownerName' => (string)($owner['contactAlias'] ?? '') !== '' ? (string)$owner['contactAlias'] : (string)$owner['displayName'],
|
||||
'myRole' => nativeGroupRoleFromType($group['member_type'] ?? 0),
|
||||
'myAlias' => $myAlias,
|
||||
'myDisplayName' => $myAlias !== '' ? $myAlias : (string)$me['displayName'],
|
||||
'muteSetting' => [
|
||||
'mode' => 'off',
|
||||
'scheduleType' => 'once',
|
||||
'startsAtMs' => 0,
|
||||
'endsAtMs' => 0,
|
||||
'dailyStart' => '',
|
||||
'dailyEnd' => '',
|
||||
'enabled' => false,
|
||||
],
|
||||
'native' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function nativeGroupMembersAction(PDO $imDb, string $userId, string $groupId): array
|
||||
{
|
||||
ensureNativeGroupMember($imDb, $groupId, $userId);
|
||||
|
||||
$statement = $imDb->prepare(
|
||||
'select `_mid`, `_alias`, `_type`, `_create_dt` from `t_group_member`'
|
||||
. ' where `_gid` = ? order by (`_type` = 2) desc, (`_type` = 1) desc, `_create_dt` asc limit 500'
|
||||
);
|
||||
$statement->execute([$groupId]);
|
||||
$members = $statement->fetchAll();
|
||||
$users = fetchWebUserMap($imDb, array_column($members, '_mid'), $userId);
|
||||
|
||||
return [
|
||||
'members' => array_map(static function (array $member) use ($users): array {
|
||||
$uid = (string)$member['_mid'];
|
||||
$user = $users[$uid] ?? [
|
||||
'uid' => $uid,
|
||||
'displayName' => $uid,
|
||||
'mobile' => '',
|
||||
'portrait' => '',
|
||||
'contactAlias' => '',
|
||||
'friendAlias' => '',
|
||||
'remarkAlias' => '',
|
||||
'isContact' => false,
|
||||
];
|
||||
$alias = trim((string)($member['_alias'] ?? ''));
|
||||
$contactAlias = trim((string)($user['contactAlias'] ?? ''));
|
||||
return [
|
||||
'uid' => $uid,
|
||||
'role' => nativeGroupRoleFromType($member['_type'] ?? 0),
|
||||
'alias' => $alias,
|
||||
'contactAlias' => $contactAlias,
|
||||
'friendAlias' => trim((string)($user['friendAlias'] ?? '')),
|
||||
'remarkAlias' => trim((string)($user['remarkAlias'] ?? '')),
|
||||
'isContact' => (bool)($user['isContact'] ?? false),
|
||||
'displayName' => $contactAlias !== '' ? $contactAlias : ($alias !== '' ? $alias : (string)$user['displayName']),
|
||||
'rawDisplayName' => (string)$user['displayName'],
|
||||
'groupDisplayName' => $alias,
|
||||
'mobile' => (string)($user['mobile'] ?? ''),
|
||||
'portrait' => (string)($user['portrait'] ?? ''),
|
||||
];
|
||||
}, $members),
|
||||
];
|
||||
}
|
||||
|
||||
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||
{
|
||||
$group = fetchWebGroup($appDb, $groupId);
|
||||
if (!$group) {
|
||||
respond(404, '没有找到该群组');
|
||||
return nativeGroupDetailAction($imDb, $userId, $groupId);
|
||||
}
|
||||
$member = fetchWebGroupMember($appDb, $groupId, $userId);
|
||||
if (!$member) {
|
||||
@@ -462,7 +629,7 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
|
||||
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
|
||||
{
|
||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||
respond(403, '你不在该群组中');
|
||||
return nativeGroupMembersAction($imDb, $userId, $groupId);
|
||||
}
|
||||
|
||||
$statement = $appDb->prepare(
|
||||
@@ -512,7 +679,10 @@ function updateGroupAliasAction(PDO $appDb, string $userId, string $groupId, str
|
||||
respond(400, '本群昵称过长');
|
||||
}
|
||||
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
|
||||
respond(403, '你不在该群组中');
|
||||
ensureNativeGroupMember(imDb(), $groupId, $userId);
|
||||
$update = imDb()->prepare('update `t_group_member` set `_alias` = ? where `_gid` = ? and `_mid` = ?');
|
||||
$update->execute([$alias, $groupId, $userId]);
|
||||
return ['alias' => $alias];
|
||||
}
|
||||
|
||||
$now = webImNowMs();
|
||||
@@ -781,8 +951,16 @@ function joinWebGroupAction(PDO $appDb, string $userId, string $groupKey): array
|
||||
$group = fetchWebGroup($appDb, $groupKey);
|
||||
if (!$group) {
|
||||
try {
|
||||
$native = requireImSuccess(imAdminPost('/admin/group/get_info', ['groupId' => $groupKey]), '群组不存在');
|
||||
$native = fetchNativeGroupInfo($groupKey);
|
||||
$now = webImNowMs();
|
||||
$nativeGroupId = nativeGroupInfoValue(
|
||||
$native,
|
||||
'target_id',
|
||||
nativeGroupInfoValue($native, 'targetId', nativeGroupInfoValue($native, 'group_id', nativeGroupInfoValue($native, 'groupId', $groupKey)))
|
||||
);
|
||||
$nativeName = nativeGroupInfoValue($native, 'name', $nativeGroupId);
|
||||
$nativePortrait = nativeGroupInfoValue($native, 'portrait', '');
|
||||
$nativeOwner = nativeGroupInfoValue($native, 'owner', nativeGroupInfoValue($native, 'owner_uid', nativeGroupInfoValue($native, 'ownerUid', '')));
|
||||
$statement = $appDb->prepare(
|
||||
'insert into `t_web_groups`'
|
||||
. ' (`group_id`, `category`, `name`, `portrait`, `owner_uid`, `join_mode`, `announcement`,'
|
||||
@@ -791,33 +969,38 @@ function joinWebGroupAction(PDO $appDb, string $userId, string $groupKey): array
|
||||
. ' on duplicate key update `updated_at_ms` = values(`updated_at_ms`)'
|
||||
);
|
||||
$statement->execute([
|
||||
$groupKey,
|
||||
(string)($native['name'] ?? $groupKey),
|
||||
(string)($native['portrait'] ?? ''),
|
||||
(string)($native['owner'] ?? ''),
|
||||
$groupKey,
|
||||
$nativeGroupId,
|
||||
$nativeName,
|
||||
$nativePortrait,
|
||||
$nativeOwner,
|
||||
$nativeGroupId,
|
||||
$userId,
|
||||
$now,
|
||||
$now,
|
||||
]);
|
||||
$group = fetchWebGroup($appDb, $groupKey);
|
||||
$group = fetchWebGroup($appDb, $nativeGroupId);
|
||||
} catch (Throwable) {
|
||||
respond(404, '没有找到该群组');
|
||||
}
|
||||
}
|
||||
|
||||
$groupId = (string)$group['group_id'];
|
||||
$nativeStatus = (string)($group['native_status'] ?? '');
|
||||
$nativeError = (string)($group['native_error'] ?? '');
|
||||
upsertWebGroupMember($appDb, $groupId, $userId, 'member');
|
||||
try {
|
||||
addNativeGroupMember((string)($group['owner_uid'] ?: $userId), $groupId, $userId);
|
||||
} catch (Throwable $error) {
|
||||
// Web membership is authoritative for this web build; native group sync is best-effort.
|
||||
$nativeStatus = $nativeStatus === 'ok' ? 'partial' : ($nativeStatus ?: 'fallback');
|
||||
$nativeError = mb_substr($error->getMessage() ?: '加入原生群组失败', 0, 255, 'UTF-8');
|
||||
}
|
||||
|
||||
$role = fetchWebGroupRole($appDb, $groupId, $userId);
|
||||
return [
|
||||
'group' => fetchWebGroup($appDb, $groupId),
|
||||
'conversation' => webGroupConversation($group, $role),
|
||||
'nativeStatus' => $nativeStatus,
|
||||
'nativeError' => $nativeError,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -355,6 +355,31 @@ function fetchWebUserRemarkMap(PDO $db, string $ownerId, array $targetIds): arra
|
||||
return $remarks;
|
||||
}
|
||||
|
||||
function fetchImFriendAliasMap(PDO $db, string $ownerId, array $friendIds): array
|
||||
{
|
||||
$friendIds = array_values(array_unique(array_filter(array_map(static function ($friendId): string {
|
||||
return trim((string)$friendId);
|
||||
}, $friendIds))));
|
||||
if ($ownerId === '' || !$friendIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$aliases = [];
|
||||
foreach (array_chunk($friendIds, 500) as $chunk) {
|
||||
$statement = $db->prepare(
|
||||
'select `_friend_uid`, `_alias` from `t_friend`'
|
||||
. ' where `_uid` = ? and `_friend_uid` in (' . webImPlaceholders($chunk) . ') and `_state` = 0'
|
||||
);
|
||||
$statement->execute(array_merge([$ownerId], $chunk));
|
||||
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$aliases[(string)$row['_friend_uid']] = trim((string)($row['_alias'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
return $aliases;
|
||||
}
|
||||
|
||||
function saveWebUserRemark(PDO $db, string $ownerId, string $targetId, string $alias): void
|
||||
{
|
||||
if ($ownerId === '' || $targetId === '') {
|
||||
|
||||
+677
-9
@@ -132,7 +132,7 @@ function fetchMessageRows(PDO $db, array $messageIds): array
|
||||
|
||||
$messages = [];
|
||||
foreach ($idsByTable as $table => $ids) {
|
||||
$sql = 'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
|
||||
$sql = 'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_data`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
|
||||
. ' from `' . $table . '` where `_mid` in (' . placeholders($ids) . ')';
|
||||
$statement = $db->prepare($sql);
|
||||
$statement->execute($ids);
|
||||
@@ -232,12 +232,21 @@ function messagePreviewText(?array $message): string
|
||||
return '';
|
||||
}
|
||||
$attachment = is_array($message['attachment'] ?? null) ? $message['attachment'] : null;
|
||||
$contentType = (int)($message['contentType'] ?? 0);
|
||||
$messageType = (string)($message['type'] ?? '');
|
||||
$placeholderIsImage = $contentType === 3 || $messageType === 'image';
|
||||
$text = trim((string)($message['text'] ?? ''));
|
||||
if ($contentType !== 1 && $text === '[暂不支持的消息类型]') {
|
||||
$text = '';
|
||||
}
|
||||
if ($placeholderIsImage && ($text === '[图片]' || $text === '图片')) {
|
||||
$text = '';
|
||||
}
|
||||
if ($attachment) {
|
||||
$text = trim((string)($message['text'] ?? ''));
|
||||
return $text !== '' ? $text : attachmentFallbackText($attachment);
|
||||
}
|
||||
|
||||
return (string)($message['text'] ?? '');
|
||||
return $text;
|
||||
}
|
||||
|
||||
function fetchUserInfoMap(PDO $db, array $userIds, string $ownerId = ''): array
|
||||
@@ -405,6 +414,42 @@ function isWebGroupTarget(PDO $appDb, string $targetId): bool
|
||||
return (bool)$statement->fetchColumn();
|
||||
}
|
||||
|
||||
function nativeGroupRoleFromType(mixed $type): string
|
||||
{
|
||||
$value = (int)$type;
|
||||
if ($value === 2) {
|
||||
return 'owner';
|
||||
}
|
||||
if ($value === 1) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
return 'member';
|
||||
}
|
||||
|
||||
function isNativeGroupTarget(PDO $db, string $targetId): bool
|
||||
{
|
||||
if ($targetId === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$statement = $db->prepare('select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
|
||||
$statement->execute([$targetId]);
|
||||
|
||||
return (bool)$statement->fetchColumn();
|
||||
}
|
||||
|
||||
function nativeGroupRole(PDO $db, string $userId, string $groupId): string
|
||||
{
|
||||
$statement = $db->prepare(
|
||||
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
|
||||
);
|
||||
$statement->execute([$groupId, $userId]);
|
||||
$type = $statement->fetchColumn();
|
||||
|
||||
return $type === false ? '' : nativeGroupRoleFromType($type);
|
||||
}
|
||||
|
||||
function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $timestamp): void
|
||||
{
|
||||
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
|
||||
@@ -422,6 +467,34 @@ function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $time
|
||||
$statement->execute([$groupId, $userId, $timestamp, $now]);
|
||||
}
|
||||
|
||||
function markNativeGroupRead(PDO $db, string $userId, string $groupId, int $timestamp): void
|
||||
{
|
||||
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statement = $db->prepare(
|
||||
'select `id`, `_dt` from `t_read_report`'
|
||||
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
|
||||
. ' order by `_dt` desc limit 1'
|
||||
);
|
||||
$statement->execute([$userId, $groupId]);
|
||||
$row = $statement->fetch();
|
||||
|
||||
if ($row) {
|
||||
if ($timestamp > (int)$row['_dt']) {
|
||||
$update = $db->prepare('update `t_read_report` set `_dt` = ? where `id` = ?');
|
||||
$update->execute([$timestamp, (int)$row['id']]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$insert = $db->prepare(
|
||||
'insert into `t_read_report` (`_uid`, `_type`, `_line`, `_target`, `_dt`) values (?, 1, 0, ?, ?)'
|
||||
);
|
||||
$insert->execute([$userId, $groupId, $timestamp]);
|
||||
}
|
||||
|
||||
function webGroupRole(PDO $appDb, string $userId, string $groupId): string
|
||||
{
|
||||
$statement = $appDb->prepare(
|
||||
@@ -434,6 +507,50 @@ function webGroupRole(PDO $appDb, string $userId, string $groupId): string
|
||||
return is_string($role) && $role !== '' ? $role : '';
|
||||
}
|
||||
|
||||
function loadNativeGroupReadStateMap(PDO $db, string $userId, array $groupIds): array
|
||||
{
|
||||
$groupIds = array_values(array_unique(array_filter(array_map('strval', $groupIds))));
|
||||
if ($userId === '' || !$groupIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statement = $db->prepare(
|
||||
'select `_target`, max(`_dt`) as `_read_at` from `t_read_report`'
|
||||
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0'
|
||||
. ' and `_target` in (' . placeholders($groupIds) . ')'
|
||||
. ' group by `_target`'
|
||||
);
|
||||
$statement->execute(array_merge([$userId], $groupIds));
|
||||
|
||||
$readAtByGroup = [];
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$readAtByGroup[(string)$row['_target']] = (int)$row['_read_at'];
|
||||
}
|
||||
|
||||
return $readAtByGroup;
|
||||
}
|
||||
|
||||
function fetchNativeGroupMemberMap(PDO $db, string $groupId, array $userIds): array
|
||||
{
|
||||
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
|
||||
if ($groupId === '' || !$userIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statement = $db->prepare(
|
||||
'select `_mid`, `_alias`, `_type`, `_dt`, `_create_dt` from `t_group_member`'
|
||||
. ' where `_gid` = ? and `_mid` in (' . placeholders($userIds) . ')'
|
||||
);
|
||||
$statement->execute(array_merge([$groupId], $userIds));
|
||||
|
||||
$members = [];
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$members[(string)$row['_mid']] = $row;
|
||||
}
|
||||
|
||||
return $members;
|
||||
}
|
||||
|
||||
function formatWebGroupMessage(
|
||||
array $row,
|
||||
string $currentUserId,
|
||||
@@ -508,6 +625,314 @@ function formatWebGroupMessage(
|
||||
];
|
||||
}
|
||||
|
||||
function cleanupNativeMediaUrl(string $url): string
|
||||
{
|
||||
return rtrim($url, " \t\r\n\0\x0B\"'<>.,;)]}");
|
||||
}
|
||||
|
||||
function nativeMediaUrlScore(string $url): int
|
||||
{
|
||||
$path = strtolower((string)(parse_url($url, PHP_URL_PATH) ?: $url));
|
||||
$score = 0;
|
||||
if (preg_match('/\.(?:jpe?g|png|gif|webp|bmp|avif)$/i', $path)) {
|
||||
$score += 30;
|
||||
}
|
||||
if (strpos($path, 'image') !== false || strpos($path, 'media') !== false || strpos($path, 'upload') !== false) {
|
||||
$score += 5;
|
||||
}
|
||||
if (strpos($path, 'thumb') !== false || strpos($path, 'thumbnail') !== false) {
|
||||
$score -= 10;
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
|
||||
function extractNativeMediaUrlFromData($data): string
|
||||
{
|
||||
if (is_resource($data)) {
|
||||
$data = stream_get_contents($data);
|
||||
}
|
||||
if (!is_string($data) || $data === '') {
|
||||
return '';
|
||||
}
|
||||
if (!preg_match_all("~https?://[^\\s\"'<>\\x00]+~i", $data, $matches)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$urls = [];
|
||||
foreach ($matches[0] as $url) {
|
||||
$url = cleanupNativeMediaUrl((string)$url);
|
||||
if ($url !== '' && filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
$urls[$url] = $url;
|
||||
}
|
||||
}
|
||||
if (!$urls) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$urls = array_values($urls);
|
||||
usort($urls, static function (string $left, string $right): int {
|
||||
$scoreDelta = nativeMediaUrlScore($right) <=> nativeMediaUrlScore($left);
|
||||
return $scoreDelta !== 0 ? $scoreDelta : strlen($right) <=> strlen($left);
|
||||
});
|
||||
|
||||
return $urls[0] ?? '';
|
||||
}
|
||||
|
||||
function nativeAttachmentName(string $url, string $type): string
|
||||
{
|
||||
$path = (string)(parse_url($url, PHP_URL_PATH) ?: '');
|
||||
$name = trim(rawurldecode((string)basename($path)));
|
||||
if ($name !== '' && $name !== '.' && $name !== '/') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
return $type === 'image' ? '图片' : '文件';
|
||||
}
|
||||
|
||||
function nativeAttachmentMime(string $url, string $type): string
|
||||
{
|
||||
if ($type !== 'image') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$path = strtolower((string)(parse_url($url, PHP_URL_PATH) ?: ''));
|
||||
if (preg_match('/\.jpe?g$/', $path)) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (preg_match('/\.png$/', $path)) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (preg_match('/\.gif$/', $path)) {
|
||||
return 'image/gif';
|
||||
}
|
||||
if (preg_match('/\.webp$/', $path)) {
|
||||
return 'image/webp';
|
||||
}
|
||||
if (preg_match('/\.bmp$/', $path)) {
|
||||
return 'image/bmp';
|
||||
}
|
||||
if (preg_match('/\.avif$/', $path)) {
|
||||
return 'image/avif';
|
||||
}
|
||||
|
||||
return 'image/*';
|
||||
}
|
||||
|
||||
function nativeAttachmentFromRow(array $row, ?array $attachment): ?array
|
||||
{
|
||||
if ($attachment !== null) {
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
$contentType = (int)($row['_content_type'] ?? 0);
|
||||
if ($contentType !== 3 && $contentType !== 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = extractNativeMediaUrlFromData($row['_data'] ?? '');
|
||||
if ($url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$type = $contentType === 3 ? 'image' : 'file';
|
||||
|
||||
return [
|
||||
'type' => $type,
|
||||
'url' => $url,
|
||||
'name' => nativeAttachmentName($url, $type),
|
||||
'mime' => nativeAttachmentMime($url, $type),
|
||||
'size' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
function isNativePlaceholderText(string $text, int $contentType): bool
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '') {
|
||||
return false;
|
||||
}
|
||||
if ($contentType !== 1 && $text === '[暂不支持的消息类型]') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $contentType === 3 && ($text === '[图片]' || $text === '图片');
|
||||
}
|
||||
|
||||
function nativeDisplayText(string $searchableText, int $contentType, bool $hasAttachment, bool $isRecalled = false): string
|
||||
{
|
||||
$text = trim($searchableText);
|
||||
if (isNativePlaceholderText($text, $contentType)) {
|
||||
return '';
|
||||
}
|
||||
if ($text === '' && !$isRecalled) {
|
||||
return nativeMessageFallbackText($contentType, $hasAttachment);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
function nativeMessageFallbackText(int $contentType, bool $hasAttachment): string
|
||||
{
|
||||
if ($hasAttachment) {
|
||||
return '';
|
||||
}
|
||||
if ($contentType === 5) {
|
||||
return '[文件]';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function nativeMessageTableForDate(DateTimeImmutable $date): string
|
||||
{
|
||||
$localDate = $date->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
|
||||
$year = (int)$localDate->format('Y');
|
||||
$month = (int)$localDate->format('n') - 1;
|
||||
|
||||
return 't_messages_' . (($year % 3) * 12 + $month);
|
||||
}
|
||||
|
||||
function recentNativeMessageTables(int $monthsBack = 3): array
|
||||
{
|
||||
$monthsBack = max(1, min($monthsBack, 12));
|
||||
$cursor = (new DateTimeImmutable('now', new DateTimeZone(IM_DB_TIMEZONE)))
|
||||
->modify('first day of this month');
|
||||
$tables = [];
|
||||
|
||||
for ($index = 0; $index < $monthsBack; $index++) {
|
||||
$tables[] = nativeMessageTableForDate($cursor);
|
||||
$cursor = $cursor->modify('-1 month');
|
||||
}
|
||||
|
||||
return array_values(array_unique($tables));
|
||||
}
|
||||
|
||||
function loadNativeGroupRowsFromGlobal(PDO $db, array $groupIds, int $limit): array
|
||||
{
|
||||
$groupIds = array_values(array_unique(array_filter(array_map('strval', $groupIds))));
|
||||
if (!$groupIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$limit = min(max($limit, 50), 1000);
|
||||
$rows = [];
|
||||
foreach (recentNativeMessageTables(3) as $table) {
|
||||
try {
|
||||
$statement = $db->prepare(
|
||||
'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_data`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
|
||||
. ' from `' . $table . '`'
|
||||
. ' where `_type` = 1 and `_line` = 0'
|
||||
. ' and `_target` in (' . placeholders($groupIds) . ')'
|
||||
. ' order by `_mid` desc limit ' . $limit
|
||||
);
|
||||
$statement->execute($groupIds);
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($statement->fetchAll() as $row) {
|
||||
$rows[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
usort($rows, static fn($left, $right) => strcmp((string)$right['_mid'], (string)$left['_mid']));
|
||||
|
||||
$uniqueRows = [];
|
||||
$seenMessageIds = [];
|
||||
foreach ($rows as $row) {
|
||||
$messageId = (string)$row['_mid'];
|
||||
if ($messageId === '' || isset($seenMessageIds[$messageId])) {
|
||||
continue;
|
||||
}
|
||||
$seenMessageIds[$messageId] = true;
|
||||
$uniqueRows[] = $row;
|
||||
if (count($uniqueRows) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $uniqueRows;
|
||||
}
|
||||
|
||||
function formatNativeGroupMessage(
|
||||
array $row,
|
||||
string $currentUserId,
|
||||
array $group,
|
||||
?array $attachment = null,
|
||||
array $users = [],
|
||||
array $members = []
|
||||
): array {
|
||||
$groupId = (string)$row['_target'];
|
||||
$from = (string)$row['_from'];
|
||||
$senderUser = $users[$from] ?? [];
|
||||
$senderMember = $members[$from] ?? [];
|
||||
$senderAlias = trim((string)($senderMember['_alias'] ?? ''));
|
||||
$senderContactAlias = trim((string)($senderUser['_alias'] ?? ''));
|
||||
$senderFriendAlias = trim((string)($senderUser['_friend_alias'] ?? ''));
|
||||
$senderRemarkAlias = trim((string)($senderUser['_remark_alias'] ?? ''));
|
||||
$senderRawName = trim((string)($senderUser['_display_name'] ?? ''))
|
||||
?: trim((string)($senderUser['_name'] ?? ''))
|
||||
?: trim((string)($senderUser['_mobile'] ?? ''))
|
||||
?: $from;
|
||||
$senderName = $senderContactAlias
|
||||
?: $senderAlias
|
||||
?: $senderRawName;
|
||||
$senderRole = nativeGroupRoleFromType($senderMember['_type'] ?? 0);
|
||||
$messageId = (string)$row['_mid'];
|
||||
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
|
||||
$contentType = (int)($row['_content_type'] ?? 0);
|
||||
$isRecalled = $contentType === 80;
|
||||
$direction = $from === $currentUserId ? 'out' : 'in';
|
||||
$attachment = nativeAttachmentFromRow($row, $attachment);
|
||||
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
|
||||
$text = nativeDisplayText((string)($row['_searchable_key'] ?? ''), $contentType, $attachment !== null, $isRecalled);
|
||||
|
||||
return [
|
||||
'id' => $messageId,
|
||||
'messageUid' => $messageId,
|
||||
'conversationId' => 'group-' . $groupId,
|
||||
'from' => $from,
|
||||
'senderRole' => $senderRole,
|
||||
'senderName' => $senderName,
|
||||
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
|
||||
'senderProfile' => [
|
||||
'uid' => $from,
|
||||
'displayName' => $senderName,
|
||||
'rawName' => $senderRawName,
|
||||
'contactAlias' => $senderContactAlias,
|
||||
'friendAlias' => $senderFriendAlias,
|
||||
'remarkAlias' => $senderRemarkAlias,
|
||||
'groupAlias' => $senderAlias,
|
||||
'isContact' => (bool)($senderUser['_is_friend'] ?? false),
|
||||
'mobile' => (string)($senderUser['_mobile'] ?? ''),
|
||||
'address' => (string)($senderUser['_address'] ?? ''),
|
||||
'portrait' => (string)($senderUser['_portrait'] ?? ''),
|
||||
'role' => $senderRole,
|
||||
],
|
||||
'to' => $groupId,
|
||||
'target' => $groupId,
|
||||
'direction' => $direction,
|
||||
'type' => $messageType,
|
||||
'contentType' => $contentType,
|
||||
'text' => attachmentDisplayText($text, $attachment),
|
||||
'attachment' => $attachment,
|
||||
'status' => $isRecalled && $direction === 'out' ? 'recalled' : 'sent',
|
||||
'deliveryStatus' => $direction === 'out' && !$isRecalled ? 'sent' : '',
|
||||
'recalled' => $isRecalled,
|
||||
'recallStatus' => $isRecalled ? '已撤回' : '',
|
||||
'editedAt' => 0,
|
||||
'timestamp' => $timestamp,
|
||||
'activityTimestamp' => $timestamp,
|
||||
'displayTimestamp' => $timestamp,
|
||||
'conversationType' => 'group',
|
||||
'raw' => [
|
||||
'native' => true,
|
||||
'groupName' => (string)($group['_name'] ?? ''),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $limit, bool $markRead): array
|
||||
{
|
||||
ensureWebImAppTables();
|
||||
@@ -557,6 +982,81 @@ function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $
|
||||
return $messages;
|
||||
}
|
||||
|
||||
function loadNativeGroupMessages(PDO $db, string $userId, string $groupId, int $limit, bool $markRead): array
|
||||
{
|
||||
$role = nativeGroupRole($db, $userId, $groupId);
|
||||
if ($role === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groupStatement = $db->prepare('select * from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
|
||||
$groupStatement->execute([$groupId]);
|
||||
$group = $groupStatement->fetch();
|
||||
if (!is_array($group)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$table = imUserMessagesTable($userId);
|
||||
$scanLimit = min(max($limit * 4, $limit), 400);
|
||||
$statement = $db->prepare(
|
||||
'select `_mid`, `_dt` from `' . $table . '`'
|
||||
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
|
||||
. ' order by `_mid` desc limit ' . $scanLimit
|
||||
);
|
||||
$statement->execute([$userId, $groupId]);
|
||||
$userRows = $statement->fetchAll();
|
||||
$messageRows = fetchMessageRows($db, array_column($userRows, '_mid'));
|
||||
$globalRows = loadNativeGroupRowsFromGlobal($db, [$groupId], $scanLimit);
|
||||
foreach ($globalRows as $row) {
|
||||
$messageId = (string)$row['_mid'];
|
||||
if ($messageId !== '') {
|
||||
$messageRows[$messageId] = $row;
|
||||
$userRows[] = [
|
||||
'_mid' => $messageId,
|
||||
'_dt' => (string)($row['_dt'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
usort($userRows, static fn($left, $right) => strcmp((string)$right['_mid'], (string)$left['_mid']));
|
||||
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($userRows, '_mid'));
|
||||
|
||||
$messages = [];
|
||||
$seenMessageIds = [];
|
||||
foreach ($userRows as $userRow) {
|
||||
$messageId = (string)$userRow['_mid'];
|
||||
if (isset($seenMessageIds[$messageId]) || !isset($messageRows[$messageId])) {
|
||||
continue;
|
||||
}
|
||||
$seenMessageIds[$messageId] = true;
|
||||
if (!shouldDisplayMessageForUser($messageRows[$messageId], $userId)) {
|
||||
continue;
|
||||
}
|
||||
$messages[] = $messageRows[$messageId];
|
||||
if (count($messages) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
$messages = array_reverse($messages);
|
||||
$senderIds = array_values(array_unique(array_map(static fn(array $row): string => (string)$row['_from'], $messages)));
|
||||
$users = fetchUserInfoMap($db, $senderIds, $userId);
|
||||
$memberMap = fetchNativeGroupMemberMap($db, $groupId, $senderIds);
|
||||
|
||||
$readTimestamp = 0;
|
||||
$formatted = [];
|
||||
foreach ($messages as $row) {
|
||||
$messageId = (string)$row['_mid'];
|
||||
$message = formatNativeGroupMessage($row, $userId, $group, $attachmentMap[$messageId] ?? null, $users, $memberMap);
|
||||
$readTimestamp = max($readTimestamp, (int)$message['activityTimestamp']);
|
||||
$formatted[] = $message;
|
||||
}
|
||||
|
||||
if ($markRead) {
|
||||
markNativeGroupRead($db, $userId, $groupId, $readTimestamp);
|
||||
}
|
||||
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): array
|
||||
{
|
||||
ensureWebImAppTables();
|
||||
@@ -619,7 +1119,7 @@ function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): arra
|
||||
? (messagePreviewText([
|
||||
'text' => attachmentDisplayText((string)$lastMessage['text'], $lastAttachment),
|
||||
'attachment' => $lastAttachment,
|
||||
]) ?: (string)$lastMessage['text'])
|
||||
]))
|
||||
: ($isChannel ? '公告群' : ($memberCount > 0 ? $memberCount . ' 位成员' : '群组'));
|
||||
$updatedAt = $lastMessage
|
||||
? max((int)$lastMessage['created_at_ms'], (int)($lastMessage['edited_at_ms'] ?? 0))
|
||||
@@ -649,6 +1149,152 @@ function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): arra
|
||||
return $conversations;
|
||||
}
|
||||
|
||||
function loadNativeGroupConversations(PDO $db, string $userId, int $limit): array
|
||||
{
|
||||
$statement = $db->prepare(
|
||||
'select g.*, m.`_type` as `member_type`, m.`_alias` as `member_alias`'
|
||||
. ' from `t_group_member` m'
|
||||
. ' join `t_group` g on g.`_gid` = m.`_gid` and g.`_deleted` = 0'
|
||||
. ' where m.`_mid` = ?'
|
||||
. ' order by greatest(g.`_dt`, m.`_dt`) desc limit ' . min(max($limit, 50), 100)
|
||||
);
|
||||
$statement->execute([$userId]);
|
||||
$groups = $statement->fetchAll();
|
||||
if (!$groups) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groupIds = array_values(array_unique(array_map(static fn(array $group): string => (string)$group['_gid'], $groups)));
|
||||
$table = imUserMessagesTable($userId);
|
||||
$scanLimit = min(max($limit * 20, 200), 1000);
|
||||
$messageStatement = $db->prepare(
|
||||
'select `_mid`, `_target`, `_dt` from `' . $table . '`'
|
||||
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0'
|
||||
. ' and `_target` in (' . placeholders($groupIds) . ')'
|
||||
. ' order by `_mid` desc limit ' . $scanLimit
|
||||
);
|
||||
$messageStatement->execute(array_merge([$userId], $groupIds));
|
||||
$scanRows = $messageStatement->fetchAll();
|
||||
$messageRows = fetchMessageRows($db, array_column($scanRows, '_mid'));
|
||||
$globalRows = loadNativeGroupRowsFromGlobal($db, $groupIds, $scanLimit);
|
||||
foreach ($globalRows as $row) {
|
||||
$messageId = (string)$row['_mid'];
|
||||
if ($messageId !== '') {
|
||||
$messageRows[$messageId] = $row;
|
||||
$scanRows[] = [
|
||||
'_mid' => $messageId,
|
||||
'_target' => (string)($row['_target'] ?? ''),
|
||||
'_dt' => (string)($row['_dt'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
usort($scanRows, static fn($left, $right) => strcmp((string)$right['_mid'], (string)$left['_mid']));
|
||||
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($scanRows, '_mid'));
|
||||
$readAtByGroup = loadNativeGroupReadStateMap($db, $userId, $groupIds);
|
||||
|
||||
$latestByGroup = [];
|
||||
$unreadByGroup = [];
|
||||
$seenMessageIds = [];
|
||||
foreach ($scanRows as $row) {
|
||||
$messageId = (string)$row['_mid'];
|
||||
if (isset($seenMessageIds[$messageId])) {
|
||||
continue;
|
||||
}
|
||||
$seenMessageIds[$messageId] = true;
|
||||
$groupId = (string)$row['_target'];
|
||||
$message = $messageRows[$messageId] ?? null;
|
||||
if ($groupId === '' || !$message || !shouldDisplayMessageForUser($message, $userId)) {
|
||||
continue;
|
||||
}
|
||||
$activityTimestamp = timestampMs((string)($message['_dt'] ?? ''), $messageId);
|
||||
if (!isset($latestByGroup[$groupId]) || $activityTimestamp > (int)($latestByGroup[$groupId]['_activity_at'] ?? 0)) {
|
||||
$row['_activity_at'] = $activityTimestamp;
|
||||
$latestByGroup[$groupId] = $row;
|
||||
}
|
||||
if ((string)$message['_from'] !== $userId && !isRecallMessage($message)) {
|
||||
if ($activityTimestamp > (int)($readAtByGroup[$groupId] ?? 0)) {
|
||||
$unreadByGroup[$groupId] = (int)($unreadByGroup[$groupId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$groupsById = [];
|
||||
foreach ($groups as $group) {
|
||||
$groupsById[(string)$group['_gid']] = $group;
|
||||
}
|
||||
|
||||
$senderIds = [];
|
||||
foreach ($latestByGroup as $row) {
|
||||
$message = $messageRows[(string)$row['_mid']] ?? null;
|
||||
if ($message) {
|
||||
$senderIds[] = (string)$message['_from'];
|
||||
}
|
||||
}
|
||||
$users = fetchUserInfoMap($db, $senderIds, $userId);
|
||||
|
||||
$conversations = [];
|
||||
foreach ($groupsById as $groupId => $group) {
|
||||
$latestRow = $latestByGroup[$groupId] ?? null;
|
||||
$messageId = $latestRow ? (string)$latestRow['_mid'] : '';
|
||||
$message = $messageId !== '' && isset($messageRows[$messageId])
|
||||
? formatNativeGroupMessage(
|
||||
$messageRows[$messageId],
|
||||
$userId,
|
||||
$group,
|
||||
$attachmentMap[$messageId] ?? null,
|
||||
$users,
|
||||
fetchNativeGroupMemberMap($db, $groupId, [(string)$messageRows[$messageId]['_from']])
|
||||
)
|
||||
: null;
|
||||
$title = (string)($group['_name'] ?? '') ?: $groupId;
|
||||
$updatedAt = $message ? (int)$message['activityTimestamp'] : (int)($group['_dt'] ?? 0);
|
||||
$conversations[] = [
|
||||
'id' => 'group-' . $groupId,
|
||||
'type' => 'group',
|
||||
'target' => $groupId,
|
||||
'title' => $title,
|
||||
'subtitle' => $message
|
||||
? (messagePreviewText($message) ?: nativeMessageFallbackText((int)($message['contentType'] ?? 0), false))
|
||||
: (((int)($group['_member_count'] ?? 0) > 0) ? (int)$group['_member_count'] . ' 位成员' : '群组'),
|
||||
'unread' => (int)($unreadByGroup[$groupId] ?? 0),
|
||||
'portrait' => (string)($group['_portrait'] ?? ''),
|
||||
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
|
||||
'updatedAt' => $updatedAt,
|
||||
'role' => nativeGroupRoleFromType($group['member_type'] ?? 0),
|
||||
'memberCount' => (int)($group['_member_count'] ?? 0),
|
||||
'raw' => [
|
||||
'type' => 'group',
|
||||
'target' => $groupId,
|
||||
'line' => 0,
|
||||
'native' => true,
|
||||
'role' => nativeGroupRoleFromType($group['member_type'] ?? 0),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $conversations;
|
||||
}
|
||||
|
||||
function mergeConversationsById(array $conversations, array $extraConversations): array
|
||||
{
|
||||
$merged = [];
|
||||
foreach (array_merge($conversations, $extraConversations) as $conversation) {
|
||||
$id = (string)($conversation['id'] ?? '');
|
||||
if ($id === '') {
|
||||
continue;
|
||||
}
|
||||
if (!isset($merged[$id])) {
|
||||
$merged[$id] = $conversation;
|
||||
continue;
|
||||
}
|
||||
if ((int)($conversation['updatedAt'] ?? 0) > (int)($merged[$id]['updatedAt'] ?? 0)) {
|
||||
$merged[$id] = array_merge($merged[$id], $conversation);
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($merged);
|
||||
}
|
||||
|
||||
function isRecallMessage(array $row): bool
|
||||
{
|
||||
return (int)($row['_content_type'] ?? 0) === 80;
|
||||
@@ -679,8 +1325,9 @@ function formatMessage(
|
||||
$text = (string)($row['_searchable_key'] ?? '');
|
||||
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
|
||||
$direction = $from === $currentUserId ? 'out' : 'in';
|
||||
$attachment = nativeAttachmentFromRow($row, $attachment);
|
||||
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
|
||||
$messageText = $text !== '' ? $text : ($isRecalled || $attachment ? '' : '[暂不支持的消息类型]');
|
||||
$messageText = nativeDisplayText($text, $contentType, $attachment !== null, $isRecalled);
|
||||
$messageText = attachmentDisplayText($messageText, $attachment);
|
||||
$editedAt = (int)($editState['editedAt'] ?? 0);
|
||||
$activityTimestamp = messageActivityTimestamp($timestamp, $editState);
|
||||
@@ -854,10 +1501,16 @@ function loadRecentConversations(PDO $db, string $userId, int $limit): array
|
||||
}
|
||||
|
||||
try {
|
||||
$conversations = array_merge($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
|
||||
} catch (Throwable) {
|
||||
$conversations = mergeConversationsById($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
|
||||
} catch (Throwable $error) {
|
||||
error_log('web group conversation sync failed: ' . $error->getMessage());
|
||||
// Keep single-chat sync available if appdata group metadata is temporarily unavailable.
|
||||
}
|
||||
try {
|
||||
$conversations = mergeConversationsById($conversations, loadNativeGroupConversations($db, $userId, $limit));
|
||||
} catch (Throwable $error) {
|
||||
error_log('native group conversation sync failed: ' . $error->getMessage());
|
||||
}
|
||||
|
||||
usort($conversations, static fn($a, $b) => $b['updatedAt'] <=> $a['updatedAt']);
|
||||
|
||||
@@ -881,12 +1534,27 @@ try {
|
||||
markUserDelivered($db, $userId, nowMs());
|
||||
|
||||
if ($target !== '') {
|
||||
$targetId = resolveUserId($db, $target);
|
||||
if ($conversationType === 'group' || isWebGroupTarget(imAppDb(), $target)) {
|
||||
if ($conversationType === 'group') {
|
||||
if (isWebGroupTarget(imAppDb(), $target)) {
|
||||
respond(0, 'success', [
|
||||
'messages' => loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
|
||||
]);
|
||||
}
|
||||
if (isNativeGroupTarget($db, $target)) {
|
||||
respond(0, 'success', [
|
||||
'messages' => loadNativeGroupMessages($db, $userId, $target, $limit, $markRead),
|
||||
]);
|
||||
}
|
||||
respond(0, 'success', [
|
||||
'messages' => [],
|
||||
]);
|
||||
}
|
||||
if (isWebGroupTarget(imAppDb(), $target)) {
|
||||
respond(0, 'success', [
|
||||
'messages' => loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
|
||||
]);
|
||||
}
|
||||
$targetId = resolveUserId($db, $target);
|
||||
respond(0, 'success', [
|
||||
'messages' => loadConversationMessages($db, $userId, $targetId, $limit, $markRead),
|
||||
]);
|
||||
|
||||
+219
-55
@@ -6,12 +6,21 @@ header('Cache-Control: no-store');
|
||||
|
||||
require __DIR__ . '/im_web_bootstrap.php';
|
||||
|
||||
const WEB_MEDIA_ROOT = '/opt/im-server/media';
|
||||
define(
|
||||
'WEB_MEDIA_ROOT',
|
||||
webImEnv(
|
||||
'JC_WEB_IM_MEDIA_ROOT',
|
||||
is_dir('/var/www/im/public/media')
|
||||
? '/var/www/im/public/media'
|
||||
: (is_dir('/var/www/IM/public/media') ? '/var/www/IM/public/media' : '/opt/im-server/media')
|
||||
)
|
||||
);
|
||||
define('WEB_MEDIA_PUBLIC_PORT', (int)webImEnv('JC_WEB_IM_MEDIA_PORT', '80'));
|
||||
const WEB_MEDIA_TYPE_IMAGE = 1;
|
||||
const WEB_MEDIA_TYPE_FILE = 4;
|
||||
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
|
||||
const WEB_MINIO_PROJECT_CODE = '001';
|
||||
|
||||
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
|
||||
{
|
||||
@@ -72,6 +81,33 @@ function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $n
|
||||
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
|
||||
}
|
||||
|
||||
function nativeGroupRoleFromType(mixed $type): string
|
||||
{
|
||||
$value = (int)$type;
|
||||
if ($value === 2) {
|
||||
return 'owner';
|
||||
}
|
||||
if ($value === 1) {
|
||||
return 'admin';
|
||||
}
|
||||
|
||||
return 'member';
|
||||
}
|
||||
|
||||
function fetchNativeGroupForSender(PDO $db, string $sender, string $groupId): array
|
||||
{
|
||||
$statement = $db->prepare(
|
||||
'select g.`_gid`, g.`_name`, g.`_mute`, g.`_deleted`, m.`_type` as `member_type`'
|
||||
. ' from `t_group` g'
|
||||
. ' join `t_group_member` m on m.`_gid` = g.`_gid`'
|
||||
. ' where g.`_gid` = ? and g.`_deleted` = 0 and m.`_mid` = ? limit 1'
|
||||
);
|
||||
$statement->execute([$groupId, $sender]);
|
||||
$group = $statement->fetch();
|
||||
|
||||
return is_array($group) ? $group : [];
|
||||
}
|
||||
|
||||
function messageRequestValue(array $payload, string $key, string $default = ''): string
|
||||
{
|
||||
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
|
||||
@@ -162,7 +198,106 @@ function mediaPublicBaseUrl(): string
|
||||
return $scheme . '://' . $host . ':' . $port;
|
||||
}
|
||||
|
||||
function saveMessageAttachmentFile(array $file, string $attachmentType): array
|
||||
function messageStorageConfig(): array
|
||||
{
|
||||
$config = ['enabled' => false, 'url' => ''];
|
||||
foreach (['t_app_system_conf', 'system_conf'] as $table) {
|
||||
try {
|
||||
$statement = imAppDb()->query(
|
||||
'select `conf_name`, `conf_value` from `' . $table . '`'
|
||||
. " where lower(`conf_name`) in ('is_minioplus', 'minio_url') and `is_valid` = 1"
|
||||
);
|
||||
$rows = $statement->fetchAll();
|
||||
if (!$rows) {
|
||||
continue;
|
||||
}
|
||||
foreach ($rows as $row) {
|
||||
$name = strtolower((string)($row['conf_name'] ?? ''));
|
||||
if ($name === 'is_minioplus') {
|
||||
$config['enabled'] = (string)($row['conf_value'] ?? '0') === '1';
|
||||
} elseif ($name === 'minio_url') {
|
||||
$config['url'] = rtrim(trim((string)($row['conf_value'] ?? '')), '/');
|
||||
}
|
||||
}
|
||||
break;
|
||||
} catch (Throwable $error) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
function saveMessageAttachmentToMinio(
|
||||
array $file,
|
||||
string $attachmentType,
|
||||
string $sender,
|
||||
string $baseUrl
|
||||
): array {
|
||||
$size = (int)($file['size'] ?? 0);
|
||||
$tmpName = (string)($file['tmp_name'] ?? '');
|
||||
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
||||
throw new RuntimeException('文件上传失败');
|
||||
}
|
||||
$mime = detectedUploadMime($tmpName);
|
||||
if ($attachmentType === 'image') {
|
||||
$mime = normalizedImageUploadMime($tmpName, $mime);
|
||||
}
|
||||
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
|
||||
$content = file_get_contents($tmpName);
|
||||
if ($content === false) {
|
||||
throw new RuntimeException('读取上传文件失败');
|
||||
}
|
||||
$requestBody = json_encode([
|
||||
'projectCode' => WEB_MINIO_PROJECT_CODE,
|
||||
'fileScene' => 'temp',
|
||||
'fileName' => $originalName,
|
||||
'contentType' => $mime,
|
||||
'base64Content' => base64_encode($content),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (!is_string($requestBody)) {
|
||||
throw new RuntimeException('生成图片上传请求失败');
|
||||
}
|
||||
|
||||
$handle = curl_init($baseUrl . '/project-storage/upload');
|
||||
curl_setopt_array($handle, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 8,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: minio-' . WEB_MINIO_PROJECT_CODE . '-' . $sender,
|
||||
],
|
||||
CURLOPT_POSTFIELDS => $requestBody,
|
||||
]);
|
||||
$responseBody = curl_exec($handle);
|
||||
$status = (int)curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($handle);
|
||||
curl_close($handle);
|
||||
$response = is_string($responseBody) ? json_decode($responseBody, true) : null;
|
||||
$data = is_array($response) && is_array($response['data'] ?? null) ? $response['data'] : [];
|
||||
$url = trim((string)($data['previewUrl'] ?? $data['downloadUrl'] ?? ''));
|
||||
if ($status < 200 || $status >= 300 || (int)($response['code'] ?? -1) !== 0 || $url === '') {
|
||||
$message = trim((string)($response['message'] ?? ''));
|
||||
throw new RuntimeException(
|
||||
$message !== ''
|
||||
? 'MinIO上传失败:' . $message
|
||||
: 'MinIO上传失败' . ($curlError !== '' ? ':' . $curlError : '')
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => $attachmentType,
|
||||
'url' => $url,
|
||||
'name' => $originalName,
|
||||
'mime' => $mime,
|
||||
'size' => $size,
|
||||
'mediaType' => $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE,
|
||||
];
|
||||
}
|
||||
|
||||
function saveMessageAttachmentFile(array $file, string $attachmentType, string $sender): array
|
||||
{
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
throw new RuntimeException('文件上传失败');
|
||||
@@ -172,10 +307,6 @@ function saveMessageAttachmentFile(array $file, string $attachmentType): array
|
||||
if ($size <= 0 || $size > $maxSize) {
|
||||
throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB');
|
||||
}
|
||||
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
|
||||
throw new RuntimeException('媒体存储目录不可写');
|
||||
}
|
||||
|
||||
$tmpName = (string)($file['tmp_name'] ?? '');
|
||||
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
||||
throw new RuntimeException('文件上传失败');
|
||||
@@ -185,6 +316,17 @@ function saveMessageAttachmentFile(array $file, string $attachmentType): array
|
||||
$mime = normalizedImageUploadMime($tmpName, $mime);
|
||||
}
|
||||
|
||||
$storage = messageStorageConfig();
|
||||
if ($storage['enabled']) {
|
||||
if ($storage['url'] === '') {
|
||||
throw new RuntimeException('MinIO已开启但未配置服务地址');
|
||||
}
|
||||
return saveMessageAttachmentToMinio($file, $attachmentType, $sender, $storage['url']);
|
||||
}
|
||||
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
|
||||
throw new RuntimeException('媒体存储目录不可写');
|
||||
}
|
||||
|
||||
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
|
||||
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
|
||||
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
|
||||
@@ -249,7 +391,7 @@ function storeMessageAttachment(
|
||||
]);
|
||||
}
|
||||
|
||||
function attachmentFromRequest(string $messageType): ?array
|
||||
function attachmentFromRequest(string $messageType, string $sender): ?array
|
||||
{
|
||||
if (!in_array($messageType, ['image', 'file'], true)) {
|
||||
return null;
|
||||
@@ -260,7 +402,61 @@ function attachmentFromRequest(string $messageType): ?array
|
||||
respond(400, '缺少上传文件');
|
||||
}
|
||||
|
||||
return saveMessageAttachmentFile($attachmentFile, $messageType);
|
||||
return saveMessageAttachmentFile($attachmentFile, $messageType, $sender);
|
||||
}
|
||||
|
||||
function messagePayloadBody(string $text, ?array $attachment): array
|
||||
{
|
||||
$pushContent = $text !== ''
|
||||
? $text
|
||||
: ($attachment
|
||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
||||
: $text);
|
||||
$payloadBody = [
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
||||
'searchableContent' => $pushContent,
|
||||
'pushContent' => $pushContent,
|
||||
'content' => $text,
|
||||
'persistFlag' => 3,
|
||||
];
|
||||
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);
|
||||
}
|
||||
|
||||
return $payloadBody;
|
||||
}
|
||||
|
||||
function sendNativeGroupMessage(string $sender, string $target, string $text, ?array $attachment): array
|
||||
{
|
||||
$result = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||
'sender' => $sender,
|
||||
'conv' => [
|
||||
'type' => 1,
|
||||
'target' => $target,
|
||||
'line' => 0,
|
||||
],
|
||||
'payload' => messagePayloadBody($text, $attachment),
|
||||
'userMessage' => true,
|
||||
]), '原生群消息发送失败');
|
||||
|
||||
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
|
||||
$timestamp = (int)($result['timestamp'] ?? webImNowMs());
|
||||
if ($attachment && $messageUid !== '') {
|
||||
ensureWebImAppTables();
|
||||
storeMessageAttachment(imAppDb(), $messageUid, 'group', $target, $sender, $attachment, $timestamp);
|
||||
}
|
||||
|
||||
return [
|
||||
'messageUid' => $messageUid !== '' ? $messageUid : null,
|
||||
'timestamp' => $timestamp,
|
||||
'attachment' => $attachment,
|
||||
];
|
||||
}
|
||||
|
||||
$contentTypeHeader = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
||||
@@ -292,7 +488,17 @@ try {
|
||||
$memberStatement->execute([$target, $sender]);
|
||||
$group = $memberStatement->fetch();
|
||||
if (!$group) {
|
||||
respond(403, '你不在该群组中');
|
||||
$nativeGroup = fetchNativeGroupForSender(imDb(), $sender, $target);
|
||||
if (!$nativeGroup) {
|
||||
respond(403, '你不在该群组中');
|
||||
}
|
||||
$attachment = attachmentFromRequest($messageType, $sender);
|
||||
$nativeResult = sendNativeGroupMessage($sender, $target, $text, $attachment);
|
||||
respond(0, 'success', [
|
||||
...$nativeResult,
|
||||
'senderRole' => nativeGroupRoleFromType($nativeGroup['member_type'] ?? 0),
|
||||
'native' => true,
|
||||
]);
|
||||
}
|
||||
$category = (string)($group['category'] ?? 'group');
|
||||
$role = (string)($group['role'] ?? 'member');
|
||||
@@ -304,7 +510,7 @@ try {
|
||||
if ($category !== 'channel' && isGroupMutedForSender($appDb, $target, $role, $now)) {
|
||||
respond(403, '当前群组处于禁言时段');
|
||||
}
|
||||
$attachment = attachmentFromRequest($messageType);
|
||||
$attachment = attachmentFromRequest($messageType, $sender);
|
||||
$messageUid = webImMessageUid();
|
||||
$insert = $appDb->prepare(
|
||||
'insert into `t_web_group_messages`'
|
||||
@@ -320,27 +526,6 @@ try {
|
||||
|
||||
$nativeError = '';
|
||||
try {
|
||||
$pushContent = $text !== ''
|
||||
? $text
|
||||
: ($attachment
|
||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
||||
: $text);
|
||||
$payloadBody = [
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
||||
'searchableContent' => $pushContent,
|
||||
'pushContent' => $pushContent,
|
||||
'content' => $text,
|
||||
'persistFlag' => 3,
|
||||
];
|
||||
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);
|
||||
}
|
||||
requireImSuccess(imAdminPost('/admin/message/send', [
|
||||
'sender' => $sender,
|
||||
'conv' => [
|
||||
@@ -348,7 +533,7 @@ try {
|
||||
'target' => $target,
|
||||
'line' => 0,
|
||||
],
|
||||
'payload' => $payloadBody,
|
||||
'payload' => messagePayloadBody($text, $attachment),
|
||||
'userMessage' => true,
|
||||
]), '原生群消息发送失败');
|
||||
} catch (Throwable $error) {
|
||||
@@ -364,28 +549,7 @@ try {
|
||||
]);
|
||||
}
|
||||
|
||||
$attachment = attachmentFromRequest($messageType);
|
||||
$pushContent = $text !== ''
|
||||
? $text
|
||||
: ($attachment
|
||||
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
|
||||
: $text);
|
||||
$payloadBody = [
|
||||
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
|
||||
'searchableContent' => $pushContent,
|
||||
'pushContent' => $pushContent,
|
||||
'content' => $text,
|
||||
'persistFlag' => 3,
|
||||
];
|
||||
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);
|
||||
}
|
||||
$attachment = attachmentFromRequest($messageType, $sender);
|
||||
|
||||
$result = requireImSuccess(imAdminPost('/admin/message/send', [
|
||||
'sender' => $sender,
|
||||
@@ -394,7 +558,7 @@ try {
|
||||
'target' => $target,
|
||||
'line' => 0,
|
||||
],
|
||||
'payload' => $payloadBody,
|
||||
'payload' => messagePayloadBody($text, $attachment),
|
||||
'userMessage' => true,
|
||||
]), '发送失败');
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
imRoutePort: 80,
|
||||
useWss: false,
|
||||
wsPath: '/ws',
|
||||
sdkMode: 'http'
|
||||
sdkMode: 'http',
|
||||
syncIntervalMs: 15000,
|
||||
sseEnabled: true,
|
||||
sseTicketApi: '/api/sse_ticket.php',
|
||||
sseUrl: '/ws-events'
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -303,12 +303,15 @@
|
||||
</header>
|
||||
<div v-if="groupBusy" class="group-loading">正在加载</div>
|
||||
<div v-else class="group-member-list">
|
||||
<div v-for="member in groupMembers" :key="member.uid" class="group-member-row">
|
||||
<div v-for="member in sortedGroupMembers" :key="member.uid" class="group-member-row">
|
||||
<img v-if="member.portrait" class="conversation-avatar image-avatar" :src="member.portrait" alt="">
|
||||
<span v-else class="conversation-avatar">{{ memberAvatar(member) }}</span>
|
||||
<span>
|
||||
<strong>{{ member.displayName }}</strong>
|
||||
<small>{{ member.role === 'owner' ? '群主' : member.rawDisplayName }}</small>
|
||||
<small v-if="detailRoleLabel(member.role)" class="group-member-role">
|
||||
{{ detailRoleLabel(member.role) }}
|
||||
</small>
|
||||
<small v-else>{{ member.rawDisplayName }}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!groupMembers.length" class="contact-picker-empty">暂无群员</div>
|
||||
@@ -576,6 +579,17 @@ const currentGroupRole = computed(() => {
|
||||
});
|
||||
const isGroupOwner = computed(() => currentGroupRole.value === 'owner');
|
||||
const canManageGroup = computed(() => ['owner', 'admin'].includes(currentGroupRole.value));
|
||||
const sortedGroupMembers = computed(() => {
|
||||
const roleOrder = { owner: 0, admin: 1, member: 2 };
|
||||
return groupMembers.value
|
||||
.map((member, index) => ({ member, index }))
|
||||
.sort((left, right) => {
|
||||
const leftOrder = roleOrder[left.member.role] ?? 3;
|
||||
const rightOrder = roleOrder[right.member.role] ?? 3;
|
||||
return leftOrder - rightOrder || left.index - right.index;
|
||||
})
|
||||
.map(item => item.member);
|
||||
});
|
||||
const adminCandidateMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
|
||||
const transferableMembers = computed(() => groupMembers.value.filter(member => member.role !== 'owner'));
|
||||
const messageDetail = computed(() => conversations.messageDetail || null);
|
||||
|
||||
@@ -17,9 +17,36 @@
|
||||
<button type="button" title="取消引用" @click="$emit('cancel-quote')">×</button>
|
||||
</div>
|
||||
<div class="composer-tools">
|
||||
<button type="button" title="表情" :disabled="disabled" @click="toggleEmojiPicker">☺</button>
|
||||
<button type="button" title="图片" :disabled="disabled" @click="chooseImage">▧</button>
|
||||
<button type="button" title="文件" :disabled="disabled" @click="chooseFile">▤</button>
|
||||
<button type="button" title="表情" aria-label="表情" :disabled="disabled" @click="toggleEmojiPicker">☺</button>
|
||||
<button
|
||||
type="button"
|
||||
class="composer-tool-button image-tool"
|
||||
title="图片"
|
||||
aria-label="图片"
|
||||
:disabled="disabled"
|
||||
@click="chooseImage"
|
||||
>
|
||||
<svg class="composer-tool-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2.5"></rect>
|
||||
<circle cx="8.2" cy="9.2" r="1.6"></circle>
|
||||
<path d="M6.5 17l4.2-4.7 3.1 3.4 1.8-2 2.9 3.3"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="composer-tool-button file-tool"
|
||||
title="文件"
|
||||
aria-label="文件"
|
||||
:disabled="disabled"
|
||||
@click="chooseFile"
|
||||
>
|
||||
<svg class="composer-tool-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M7 3.8h7.2L19 8.6V20a1.8 1.8 0 0 1-1.8 1.8H7A1.8 1.8 0 0 1 5.2 20V5.6A1.8 1.8 0 0 1 7 3.8z"></path>
|
||||
<path d="M14 4v5h5"></path>
|
||||
<path d="M8.5 13h7"></path>
|
||||
<path d="M8.5 16.5h5.2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<input ref="imageInput" type="file" accept="image/*" multiple @change="selectAttachment($event, 'image')">
|
||||
<input ref="fileInput" type="file" multiple @change="selectAttachment($event, 'file')">
|
||||
<div v-if="emojiOpen" class="emoji-picker" @click.stop>
|
||||
@@ -33,6 +60,22 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pendingAttachments.length" class="composer-attachment-preview">
|
||||
<article
|
||||
v-for="(attachment, index) in pendingAttachments"
|
||||
:key="attachment.id"
|
||||
class="composer-attachment-item"
|
||||
>
|
||||
<img
|
||||
v-if="attachment.type === 'image'"
|
||||
:src="attachment.previewUrl"
|
||||
:alt="attachment.file.name || '待发送图片'"
|
||||
>
|
||||
<span v-else class="composer-attachment-file-icon">▤</span>
|
||||
<span class="composer-attachment-name">{{ attachment.file.name }}</span>
|
||||
<button type="button" title="移除" aria-label="移除待发送文件" @click="removePendingAttachment(index)">×</button>
|
||||
</article>
|
||||
</div>
|
||||
<textarea
|
||||
ref="textareaEl"
|
||||
v-model="text"
|
||||
@@ -43,13 +86,13 @@
|
||||
></textarea>
|
||||
<div class="composer-footer">
|
||||
<span>Enter 发送,Shift + Enter 换行</span>
|
||||
<button class="send-button" :disabled="disabled || !text.trim()">发送</button>
|
||||
<button class="send-button" :disabled="disabled || (!text.trim() && !pendingAttachments.length)">发送</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { onBeforeUnmount, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
disabled: {
|
||||
@@ -69,6 +112,7 @@ const dropActive = ref(false);
|
||||
const textareaEl = ref(null);
|
||||
const imageInput = ref(null);
|
||||
const fileInput = ref(null);
|
||||
const pendingAttachments = ref([]);
|
||||
let dragDepth = 0;
|
||||
const emojiList = [
|
||||
'😀', '😄', '😂', '😊', '😍', '😘', '😎', '😢',
|
||||
@@ -78,10 +122,22 @@ const emojiList = [
|
||||
|
||||
function submit() {
|
||||
const value = text.value.trim();
|
||||
if (!value) {
|
||||
const attachments = [...pendingAttachments.value];
|
||||
if (!value && !attachments.length) {
|
||||
return;
|
||||
}
|
||||
emit('send', value);
|
||||
if (attachments.length) {
|
||||
attachments.forEach((attachment, index) => {
|
||||
emit('send-attachment', {
|
||||
type: attachment.type,
|
||||
file: attachment.file,
|
||||
text: index === 0 ? value : ''
|
||||
});
|
||||
});
|
||||
clearPendingAttachments();
|
||||
} else {
|
||||
emit('send', value);
|
||||
}
|
||||
text.value = '';
|
||||
emojiOpen.value = false;
|
||||
}
|
||||
@@ -130,18 +186,34 @@ function emitFiles(files, fallbackType = 'file') {
|
||||
return;
|
||||
}
|
||||
|
||||
const caption = text.value.trim();
|
||||
validFiles.forEach((file, index) => {
|
||||
emit('send-attachment', {
|
||||
type: attachmentTypeForFile(file, fallbackType),
|
||||
validFiles.forEach(file => {
|
||||
const type = attachmentTypeForFile(file, fallbackType);
|
||||
pendingAttachments.value.push({
|
||||
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
type,
|
||||
file,
|
||||
text: index === 0 ? caption : ''
|
||||
previewUrl: type === 'image' ? URL.createObjectURL(file) : ''
|
||||
});
|
||||
});
|
||||
text.value = '';
|
||||
emojiOpen.value = false;
|
||||
}
|
||||
|
||||
function releasePendingAttachment(attachment) {
|
||||
if (attachment?.previewUrl) {
|
||||
URL.revokeObjectURL(attachment.previewUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function removePendingAttachment(index) {
|
||||
const [attachment] = pendingAttachments.value.splice(index, 1);
|
||||
releasePendingAttachment(attachment);
|
||||
}
|
||||
|
||||
function clearPendingAttachments() {
|
||||
pendingAttachments.value.forEach(releasePendingAttachment);
|
||||
pendingAttachments.value = [];
|
||||
}
|
||||
|
||||
function selectAttachment(event, type) {
|
||||
const input = event.target;
|
||||
const files = input.files ? Array.from(input.files) : [];
|
||||
@@ -220,4 +292,6 @@ function handlePaste(event) {
|
||||
event.preventDefault();
|
||||
emitFiles(imageFiles, 'image');
|
||||
}
|
||||
|
||||
onBeforeUnmount(clearPendingAttachments);
|
||||
</script>
|
||||
|
||||
@@ -47,7 +47,13 @@
|
||||
class="message-image"
|
||||
@click.stop="handleImageClick(message)"
|
||||
>
|
||||
<img :src="message.attachment.url" :alt="message.attachment.name || '图片'">
|
||||
<img
|
||||
:src="message.attachment.url"
|
||||
:alt="message.attachment.name || '图片'"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@error="retryImageLoad"
|
||||
>
|
||||
</button>
|
||||
<a
|
||||
v-else-if="isFileMessage(message)"
|
||||
@@ -63,7 +69,7 @@
|
||||
<small>{{ formatFileSize(message.attachment.size) }}</small>
|
||||
</span>
|
||||
</a>
|
||||
<p v-if="message.text">{{ message.text }}</p>
|
||||
<p v-if="displayMessageText(message)">{{ displayMessageText(message) }}</p>
|
||||
<time>
|
||||
<span v-if="isRecallNotice(message)" class="recall-mark">{{ message.recallStatus || '已撤回' }}</span>
|
||||
<span>{{ formatTime(message.displayTimestamp || message.timestamp) }}</span>
|
||||
@@ -130,6 +136,8 @@
|
||||
:src="imageViewer.url"
|
||||
:alt="imageViewer.name || '图片'"
|
||||
:style="imageViewerStyle"
|
||||
decoding="async"
|
||||
@error="retryImageLoad"
|
||||
draggable="false"
|
||||
>
|
||||
</div>
|
||||
@@ -325,7 +333,16 @@ const menuItems = computed(() => {
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (listEl.value) {
|
||||
listEl.value.scrollTop = listEl.value.scrollHeight;
|
||||
const scroll = () => {
|
||||
if (listEl.value) {
|
||||
listEl.value.scrollTop = listEl.value.scrollHeight;
|
||||
}
|
||||
};
|
||||
scroll();
|
||||
requestAnimationFrame(() => {
|
||||
scroll();
|
||||
requestAnimationFrame(scroll);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -372,6 +389,22 @@ function messageIdentity(message) {
|
||||
return String(message.messageUid || message.id || '');
|
||||
}
|
||||
|
||||
function displayMessageText(message) {
|
||||
const text = String(message?.text || '').replace(/^引用:.*(?:\r?\n|$)/, '').trim();
|
||||
const contentType = Number(message?.contentType || 0);
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
if (contentType !== 1 && text === '[暂不支持的消息类型]') {
|
||||
return '';
|
||||
}
|
||||
if ((contentType === 3 || message?.type === 'image') && (text === '[图片]' || text === '图片')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function canOpenMessageMenu(message) {
|
||||
return message && message.status !== 'failed' && message.status !== 'sending';
|
||||
}
|
||||
@@ -552,6 +585,30 @@ function formatFileSize(size) {
|
||||
return bytes > 0 ? `${bytes} B` : '';
|
||||
}
|
||||
|
||||
function retryImageLoad(event) {
|
||||
const image = event.currentTarget;
|
||||
if (!(image instanceof HTMLImageElement)) {
|
||||
return;
|
||||
}
|
||||
const attempts = Number(image.dataset.loadRetries || 0);
|
||||
if (attempts >= 2) {
|
||||
return;
|
||||
}
|
||||
const originalUrl = image.dataset.originalUrl || image.getAttribute('src') || '';
|
||||
if (!originalUrl) {
|
||||
return;
|
||||
}
|
||||
image.dataset.originalUrl = originalUrl;
|
||||
image.dataset.loadRetries = String(attempts + 1);
|
||||
window.setTimeout(() => {
|
||||
if (!image.isConnected) {
|
||||
return;
|
||||
}
|
||||
const separator = originalUrl.includes('?') ? '&' : '?';
|
||||
image.src = `${originalUrl}${separator}_image_retry=${attempts + 1}`;
|
||||
}, 500 * (attempts + 1));
|
||||
}
|
||||
|
||||
function handleMessageClick(message) {
|
||||
if (props.selectionMode) {
|
||||
emit('toggle-select', message);
|
||||
|
||||
@@ -52,6 +52,10 @@
|
||||
<div v-else-if="!conversations.conversations.length && !conversations.usingDemoData" class="empty-list">
|
||||
<strong>暂无会话</strong>
|
||||
<span>收到或发起消息后会出现在这里</span>
|
||||
<div class="empty-list-actions">
|
||||
<button type="button" class="primary-inline-action" @click="openAction('createGroup')">新建群组</button>
|
||||
<button type="button" class="ghost-inline-action" @click="openAction('joinGroup')">加入群组</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+4
-1
@@ -27,7 +27,10 @@ export const appConfig = {
|
||||
wsPath: runtimeConfig.wsPath || process.env.VUE_APP_WS_PATH || '/ws',
|
||||
wsUrl: runtimeConfig.wsUrl || process.env.VUE_APP_WS_URL || '',
|
||||
sdkMode: runtimeConfig.sdkMode || process.env.VUE_APP_IM_MODE || 'http',
|
||||
syncIntervalMs: Number(runtimeConfig.syncIntervalMs || process.env.VUE_APP_SYNC_INTERVAL_MS || 300),
|
||||
syncIntervalMs: Number(runtimeConfig.syncIntervalMs || process.env.VUE_APP_SYNC_INTERVAL_MS || 15000),
|
||||
sseEnabled: runtimeConfig.sseEnabled ?? process.env.VUE_APP_SSE_ENABLED === 'true',
|
||||
sseTicketApi: runtimeConfig.sseTicketApi || process.env.VUE_APP_SSE_TICKET_API || '/api/sse_ticket.php',
|
||||
sseUrl: runtimeConfig.sseUrl || process.env.VUE_APP_SSE_URL || '/ws-events',
|
||||
platform: 5,
|
||||
clientIdStorageKey: 'qmt-web-im-client-id',
|
||||
authTokenStoragePrefix: 'qmt-web-im-auth-token-'
|
||||
|
||||
@@ -719,7 +719,16 @@ export class HttpImClient {
|
||||
throw new Error(response.data?.message || (channel ? '创建公告群失败' : '创建群组失败'));
|
||||
}
|
||||
|
||||
return this.normalizeConversation(response.data?.result?.conversation || {});
|
||||
const result = response.data?.result || {};
|
||||
const conversation = this.normalizeConversation(result.conversation || {});
|
||||
conversation.nativeStatus = result.nativeStatus || '';
|
||||
conversation.nativeError = result.nativeError || '';
|
||||
conversation.raw = {
|
||||
...(conversation.raw || {}),
|
||||
nativeStatus: conversation.nativeStatus,
|
||||
nativeError: conversation.nativeError
|
||||
};
|
||||
return conversation;
|
||||
}
|
||||
|
||||
async joinGroup(userId, groupKey) {
|
||||
@@ -733,7 +742,16 @@ export class HttpImClient {
|
||||
throw new Error(response.data?.message || '加入群组失败');
|
||||
}
|
||||
|
||||
return this.normalizeConversation(response.data?.result?.conversation || {});
|
||||
const result = response.data?.result || {};
|
||||
const conversation = this.normalizeConversation(result.conversation || {});
|
||||
conversation.nativeStatus = result.nativeStatus || '';
|
||||
conversation.nativeError = result.nativeError || '';
|
||||
conversation.raw = {
|
||||
...(conversation.raw || {}),
|
||||
nativeStatus: conversation.nativeStatus,
|
||||
nativeError: conversation.nativeError
|
||||
};
|
||||
return conversation;
|
||||
}
|
||||
|
||||
async broadcastMessage(userId, targetIds, text) {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import axios from 'axios';
|
||||
import { appConfig } from '@/config';
|
||||
import { imClient } from '@/services/imClient';
|
||||
|
||||
class RealtimeNotificationClient {
|
||||
constructor() {
|
||||
this.source = null;
|
||||
this.userId = '';
|
||||
this.handler = null;
|
||||
this.reconnectTimer = null;
|
||||
this.reconnectAttempt = 0;
|
||||
this.generation = 0;
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
return this.source?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
start(userId, handler) {
|
||||
this.stop();
|
||||
if (!appConfig.sseEnabled || !userId || typeof window.WebSocket !== 'function') return;
|
||||
this.userId = userId;
|
||||
this.handler = handler;
|
||||
this.generation += 1;
|
||||
this.open(this.generation);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.generation += 1;
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
if (this.source) this.source.close();
|
||||
this.source = null;
|
||||
this.userId = '';
|
||||
this.handler = null;
|
||||
this.reconnectAttempt = 0;
|
||||
}
|
||||
|
||||
async open(generation) {
|
||||
try {
|
||||
const response = await axios.post(appConfig.sseTicketApi, { userId: this.userId }, {
|
||||
withCredentials: false,
|
||||
headers: imClient.getAppAuthHeaders(appConfig.appServer)
|
||||
});
|
||||
if (generation !== this.generation) return;
|
||||
if (response.data?.code !== 0) throw new Error(response.data?.message || '实时通知凭证申请失败');
|
||||
const ticket = response.data?.result?.ticket || '';
|
||||
if (!ticket) throw new Error('实时通知凭证为空');
|
||||
const separator = appConfig.sseUrl.includes('?') ? '&' : '?';
|
||||
const endpoint = `${appConfig.sseUrl}${separator}ticket=${encodeURIComponent(ticket)}`;
|
||||
const socketUrl = endpoint.startsWith('ws://') || endpoint.startsWith('wss://')
|
||||
? endpoint
|
||||
: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}${endpoint.startsWith('/') ? '' : '/'}${endpoint}`;
|
||||
const source = new WebSocket(socketUrl);
|
||||
this.source = source;
|
||||
source.onopen = () => {
|
||||
this.reconnectAttempt = 0;
|
||||
};
|
||||
source.onmessage = event => {
|
||||
if (generation !== this.generation) return;
|
||||
let payload = {};
|
||||
try { payload = JSON.parse(event.data || '{}'); } catch (error) { payload = {}; }
|
||||
if (payload.type !== 'message-notification') return;
|
||||
this.handler?.(payload);
|
||||
};
|
||||
source.onerror = () => {
|
||||
if (generation !== this.generation) return;
|
||||
source.close();
|
||||
if (this.source === source) this.source = null;
|
||||
this.scheduleReconnect(generation);
|
||||
};
|
||||
source.onclose = () => {
|
||||
if (generation !== this.generation || this.source !== source) return;
|
||||
this.source = null;
|
||||
this.scheduleReconnect(generation);
|
||||
};
|
||||
} catch (error) {
|
||||
if (generation === this.generation) this.scheduleReconnect(generation);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleReconnect(generation) {
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
const delay = Math.min(30000, 1000 * (2 ** Math.min(this.reconnectAttempt, 5)));
|
||||
this.reconnectAttempt += 1;
|
||||
this.reconnectTimer = window.setTimeout(() => this.open(generation), delay);
|
||||
}
|
||||
}
|
||||
|
||||
export const realtimeNotifications = new RealtimeNotificationClient();
|
||||
+180
-26
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia';
|
||||
import { imClient } from '@/services/imClient';
|
||||
import { appConfig } from '@/config';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { realtimeNotifications } from '@/services/realtimeNotifications';
|
||||
|
||||
const LOCAL_CONVERSATION_STORAGE_KEY = 'qmt-web-im-local-conversations-v2';
|
||||
const LOCAL_FAVORITE_MESSAGES_KEY = 'qmt-web-im-favorite-messages-v1';
|
||||
@@ -70,14 +71,84 @@ function attachmentLabel(message) {
|
||||
return `[文件] ${attachment.name || '文件'}`;
|
||||
}
|
||||
|
||||
function cleanPlaceholderText(text, message = {}) {
|
||||
const value = String(text || '').replace(/\s+/g, ' ').trim();
|
||||
const contentType = Number(message?.contentType || 0);
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
if (contentType !== 1 && value === '[暂不支持的消息类型]') {
|
||||
return '';
|
||||
}
|
||||
if ((contentType === 3 || message?.type === 'image') && (value === '[图片]' || value === '图片')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function messagePreviewText(message) {
|
||||
if (isRecalledMessage(message)) {
|
||||
return `[${message?.recallStatus || '已撤回'}]`;
|
||||
}
|
||||
const text = String(message?.text || '').replace(/\s+/g, ' ').trim();
|
||||
const text = cleanPlaceholderText(message?.text, message);
|
||||
return text || attachmentLabel(message);
|
||||
}
|
||||
|
||||
function sanitizeConversation(conversation) {
|
||||
if (!conversation) {
|
||||
return conversation;
|
||||
}
|
||||
return {
|
||||
...conversation,
|
||||
subtitle: cleanPlaceholderText(conversation.subtitle, conversation)
|
||||
};
|
||||
}
|
||||
|
||||
function isHttpUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value || '').trim());
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fileNameFromUrl(url, fallback = '图片') {
|
||||
try {
|
||||
const path = new URL(url).pathname;
|
||||
const name = decodeURIComponent(path.split('/').filter(Boolean).pop() || '');
|
||||
return name || fallback;
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMessageForDisplay(message) {
|
||||
if (!message || message.attachment) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const contentType = Number(message.contentType || 0);
|
||||
const text = String(message.text || '').trim();
|
||||
if ((contentType === 3 || message.type === 'image') && isHttpUrl(text)) {
|
||||
return {
|
||||
...message,
|
||||
type: 'image',
|
||||
text: '',
|
||||
attachment: {
|
||||
type: 'image',
|
||||
url: text,
|
||||
name: fileNameFromUrl(text, '图片'),
|
||||
mime: 'image/*',
|
||||
size: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
function quotePrefix(message) {
|
||||
const text = messagePreviewText(message);
|
||||
return text.length > 48 ? `${text.slice(0, 48)}...` : text;
|
||||
@@ -138,9 +209,23 @@ function canMarkReadNow(options = {}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function isPageVisible() {
|
||||
return typeof document === 'undefined' || document.visibilityState === 'visible';
|
||||
}
|
||||
|
||||
function activeMessageSyncIntervalMs() {
|
||||
const configured = Number(appConfig.syncIntervalMs || 0);
|
||||
return Math.max(15000, Number.isFinite(configured) && configured > 0 ? configured : 15000);
|
||||
}
|
||||
|
||||
function conversationListSyncIntervalMs() {
|
||||
return Math.max(30000, activeMessageSyncIntervalMs() * 2);
|
||||
}
|
||||
|
||||
function mergeVisibleMessages(currentMessages, syncedMessages) {
|
||||
const merged = [...syncedMessages];
|
||||
const merged = [...syncedMessages].map(normalizeMessageForDisplay);
|
||||
currentMessages.forEach(currentMessage => {
|
||||
currentMessage = normalizeMessageForDisplay(currentMessage);
|
||||
const exists = merged.some(message => isSameMessage(message, currentMessage));
|
||||
const shouldKeepLocal = currentMessage.status === 'sending'
|
||||
|| currentMessage.status === 'failed'
|
||||
@@ -230,7 +315,10 @@ export const useConversationStore = defineStore('conversations', {
|
||||
syncInFlight: false,
|
||||
pendingReadReceiptConversationId: '',
|
||||
conversationSyncInFlight: false,
|
||||
syncAfterSendTimer: null,
|
||||
messageSyncTimer: null,
|
||||
messageSyncQueuedAt: 0,
|
||||
pendingMessageSyncOptions: {},
|
||||
lastReadSyncByConversation: {},
|
||||
optimisticMessagesByConversation: {},
|
||||
quotedMessage: null,
|
||||
forwardingMessages: [],
|
||||
@@ -248,7 +336,8 @@ export const useConversationStore = defineStore('conversations', {
|
||||
favoriteMessages: [],
|
||||
activeFavoriteKey: '',
|
||||
actionNotice: '',
|
||||
actionError: ''
|
||||
actionError: '',
|
||||
realtimeSyncTimer: null
|
||||
}),
|
||||
getters: {
|
||||
activeConversation(state) {
|
||||
@@ -295,9 +384,10 @@ export const useConversationStore = defineStore('conversations', {
|
||||
}
|
||||
this.loadScopedLocalState();
|
||||
this.startMessageSync();
|
||||
realtimeNotifications.start(session.userId, payload => this.handleRealtimeNotification(payload));
|
||||
},
|
||||
loadScopedLocalState() {
|
||||
this.conversations = readLocalConversations();
|
||||
this.conversations = readLocalConversations().map(sanitizeConversation);
|
||||
this.favoriteMessages = readFavoriteMessages();
|
||||
this.activeFavoriteKey = this.favoriteMessages[0]?.key || '';
|
||||
this.activeId = this.conversations[0]?.id || '';
|
||||
@@ -322,15 +412,21 @@ export const useConversationStore = defineStore('conversations', {
|
||||
clearUserState() {
|
||||
window.clearInterval(this.syncTimer);
|
||||
window.clearInterval(this.conversationSyncTimer);
|
||||
window.clearTimeout(this.syncAfterSendTimer);
|
||||
window.clearTimeout(this.messageSyncTimer);
|
||||
window.clearTimeout(this.attentionMessageTimer);
|
||||
window.clearTimeout(this.realtimeSyncTimer);
|
||||
realtimeNotifications.stop();
|
||||
this.syncTimer = null;
|
||||
this.conversationSyncTimer = null;
|
||||
this.syncAfterSendTimer = null;
|
||||
this.messageSyncTimer = null;
|
||||
this.messageSyncQueuedAt = 0;
|
||||
this.pendingMessageSyncOptions = {};
|
||||
this.attentionMessageTimer = null;
|
||||
this.realtimeSyncTimer = null;
|
||||
this.syncInFlight = false;
|
||||
this.conversationSyncInFlight = false;
|
||||
this.pendingReadReceiptConversationId = '';
|
||||
this.lastReadSyncByConversation = {};
|
||||
this.activeId = '';
|
||||
this.conversations = [];
|
||||
this.messagesByConversation = {};
|
||||
@@ -364,7 +460,7 @@ export const useConversationStore = defineStore('conversations', {
|
||||
imClient.on('messageReceived', message => {
|
||||
this.appendMessage(message.conversationId, message);
|
||||
if (message.conversationId === this.activeId) {
|
||||
this.syncActiveMessages({ allowReadReceipt: true });
|
||||
this.scheduleActiveMessagesSync({ allowReadReceipt: true }, 700);
|
||||
}
|
||||
});
|
||||
imClient.on('messageStatusChanged', message => {
|
||||
@@ -390,7 +486,6 @@ export const useConversationStore = defineStore('conversations', {
|
||||
}
|
||||
let value = text.trim();
|
||||
if (this.quotedMessage) {
|
||||
value = `引用:${quotePrefix(this.quotedMessage)}\n${value}`;
|
||||
this.quotedMessage = null;
|
||||
}
|
||||
const message = {
|
||||
@@ -411,8 +506,7 @@ export const useConversationStore = defineStore('conversations', {
|
||||
conversation.subtitle = value;
|
||||
conversation.updatedAt = Date.now();
|
||||
writeLocalConversations(this.conversations);
|
||||
window.clearTimeout(this.syncAfterSendTimer);
|
||||
this.syncAfterSendTimer = window.setTimeout(() => this.syncActiveMessages(), 250);
|
||||
this.scheduleActiveMessagesSync({}, 700);
|
||||
},
|
||||
sendAttachment({ type, file, text = '' }) {
|
||||
const conversation = this.activeConversation;
|
||||
@@ -421,7 +515,6 @@ export const useConversationStore = defineStore('conversations', {
|
||||
}
|
||||
let value = text.trim();
|
||||
if (this.quotedMessage) {
|
||||
value = `引用:${quotePrefix(this.quotedMessage)}\n${value}`.trim();
|
||||
this.quotedMessage = null;
|
||||
}
|
||||
const now = Date.now();
|
||||
@@ -451,8 +544,7 @@ export const useConversationStore = defineStore('conversations', {
|
||||
conversation.subtitle = messagePreviewText(message);
|
||||
conversation.updatedAt = now;
|
||||
writeLocalConversations(this.conversations);
|
||||
window.clearTimeout(this.syncAfterSendTimer);
|
||||
this.syncAfterSendTimer = window.setTimeout(() => this.syncActiveMessages(), 350);
|
||||
this.scheduleActiveMessagesSync({}, 700);
|
||||
},
|
||||
async sendTextToConversation(conversation, text) {
|
||||
if (!conversation || !text.trim()) {
|
||||
@@ -477,6 +569,7 @@ export const useConversationStore = defineStore('conversations', {
|
||||
return sentMessage;
|
||||
},
|
||||
upsertConversation(conversation) {
|
||||
conversation = sanitizeConversation(conversation);
|
||||
const index = this.conversations.findIndex(item => item.id === conversation.id);
|
||||
if (index >= 0) {
|
||||
this.conversations.splice(index, 1, {
|
||||
@@ -531,12 +624,13 @@ export const useConversationStore = defineStore('conversations', {
|
||||
const unread = conversation.id === this.activeId && canMarkReadNow() ? 0 : Number(conversation.unread || 0);
|
||||
merged.set(conversation.id, {
|
||||
...(merged.get(conversation.id) || {}),
|
||||
...conversation,
|
||||
...sanitizeConversation(conversation),
|
||||
unread
|
||||
});
|
||||
});
|
||||
|
||||
this.conversations = Array.from(merged.values())
|
||||
.map(sanitizeConversation)
|
||||
.sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0))
|
||||
.slice(0, 100);
|
||||
|
||||
@@ -556,6 +650,7 @@ export const useConversationStore = defineStore('conversations', {
|
||||
this.loadActiveMessages();
|
||||
},
|
||||
appendMessage(conversationId, message) {
|
||||
message = normalizeMessageForDisplay(message);
|
||||
if (!this.messagesByConversation[conversationId]) {
|
||||
this.messagesByConversation = {
|
||||
...this.messagesByConversation,
|
||||
@@ -615,7 +710,7 @@ export const useConversationStore = defineStore('conversations', {
|
||||
return;
|
||||
}
|
||||
|
||||
conversation.subtitle = message.text || conversation.subtitle || '';
|
||||
conversation.subtitle = messagePreviewText(message) || cleanPlaceholderText(conversation.subtitle, conversation);
|
||||
conversation.updatedAt = messageActivityTimestamp(message) || Date.now();
|
||||
if (message.direction === 'in') {
|
||||
conversation.unread = conversationId === this.activeId ? 0 : Number(conversation.unread || 0) + 1;
|
||||
@@ -624,6 +719,7 @@ export const useConversationStore = defineStore('conversations', {
|
||||
.sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0));
|
||||
},
|
||||
appendVisibleMessage(conversationId, message) {
|
||||
message = normalizeMessageForDisplay(message);
|
||||
const messages = this.visibleMessagesByConversation[conversationId] || [];
|
||||
this.visibleMessagesByConversation = {
|
||||
...this.visibleMessagesByConversation,
|
||||
@@ -632,6 +728,7 @@ export const useConversationStore = defineStore('conversations', {
|
||||
};
|
||||
},
|
||||
replaceMessage(conversationId, message) {
|
||||
message = normalizeMessageForDisplay(message);
|
||||
if (!this.messagesByConversation[conversationId]) {
|
||||
this.messagesByConversation = {
|
||||
...this.messagesByConversation,
|
||||
@@ -729,9 +826,14 @@ export const useConversationStore = defineStore('conversations', {
|
||||
if (!conversation) {
|
||||
return;
|
||||
}
|
||||
if (options.skipWhenHidden !== false && !isPageVisible()) {
|
||||
return;
|
||||
}
|
||||
const wantsReadReceipt = Boolean(options.allowReadReceipt);
|
||||
const explicitReadReceipt = wantsReadReceipt && canMarkReadNow({ userAction: Boolean(options.userAction) });
|
||||
const pollingReadReceipt = canMarkReadNow();
|
||||
const now = Date.now();
|
||||
const previousReadSyncAt = Number(this.lastReadSyncByConversation[conversation.id] || 0);
|
||||
const pollingReadReceipt = canMarkReadNow() && now - previousReadSyncAt >= 10000;
|
||||
const markRead = explicitReadReceipt || pollingReadReceipt;
|
||||
|
||||
if (this.syncInFlight) {
|
||||
@@ -747,6 +849,12 @@ export const useConversationStore = defineStore('conversations', {
|
||||
const messages = await imClient.fetchRemoteMessages(conversation, 50, {
|
||||
markRead
|
||||
});
|
||||
if (markRead) {
|
||||
this.lastReadSyncByConversation = {
|
||||
...this.lastReadSyncByConversation,
|
||||
[syncedConversationId]: Date.now()
|
||||
};
|
||||
}
|
||||
const editedIncomingMessages = incomingEditedMessages(previousMessages, messages);
|
||||
this.pruneOptimisticMessages(syncedConversationId, messages);
|
||||
this.messagesByConversation = {
|
||||
@@ -775,24 +883,63 @@ export const useConversationStore = defineStore('conversations', {
|
||||
this.syncInFlight = false;
|
||||
if (this.pendingReadReceiptConversationId === this.activeId && canMarkReadNow()) {
|
||||
this.pendingReadReceiptConversationId = '';
|
||||
window.setTimeout(() => this.syncActiveMessages({ allowReadReceipt: true }), 0);
|
||||
this.scheduleActiveMessagesSync({ allowReadReceipt: true }, 700);
|
||||
}
|
||||
}
|
||||
},
|
||||
scheduleActiveMessagesSync(options = {}, delay = 700) {
|
||||
if (!this.activeId || !isPageVisible()) {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (!this.messageSyncQueuedAt) {
|
||||
this.messageSyncQueuedAt = now;
|
||||
}
|
||||
this.pendingMessageSyncOptions = {
|
||||
...this.pendingMessageSyncOptions,
|
||||
allowReadReceipt: Boolean(
|
||||
this.pendingMessageSyncOptions.allowReadReceipt || options.allowReadReceipt
|
||||
),
|
||||
userAction: Boolean(this.pendingMessageSyncOptions.userAction || options.userAction)
|
||||
};
|
||||
const maxWait = 1200;
|
||||
const runAt = Math.min(now + Math.max(0, Number(delay) || 0), this.messageSyncQueuedAt + maxWait);
|
||||
window.clearTimeout(this.messageSyncTimer);
|
||||
this.messageSyncTimer = window.setTimeout(() => {
|
||||
const pendingOptions = this.pendingMessageSyncOptions;
|
||||
this.messageSyncTimer = null;
|
||||
this.messageSyncQueuedAt = 0;
|
||||
this.pendingMessageSyncOptions = {};
|
||||
this.syncActiveMessages(pendingOptions);
|
||||
}, Math.max(0, runAt - now));
|
||||
},
|
||||
startMessageSync() {
|
||||
this.bindReadPresenceEvents();
|
||||
if (!this.syncTimer) {
|
||||
this.syncTimer = window.setInterval(() => {
|
||||
this.syncActiveMessages();
|
||||
}, Math.max(250, appConfig.syncIntervalMs));
|
||||
if (isPageVisible() && !realtimeNotifications.isConnected()) {
|
||||
this.syncActiveMessages();
|
||||
}
|
||||
}, activeMessageSyncIntervalMs());
|
||||
}
|
||||
if (!this.conversationSyncTimer) {
|
||||
this.conversationSyncTimer = window.setInterval(() => {
|
||||
this.refreshRemoteConversations();
|
||||
}, 2000);
|
||||
if (isPageVisible() && !realtimeNotifications.isConnected()) {
|
||||
this.refreshRemoteConversations();
|
||||
}
|
||||
}, conversationListSyncIntervalMs());
|
||||
this.refreshRemoteConversations();
|
||||
}
|
||||
},
|
||||
handleRealtimeNotification() {
|
||||
window.clearTimeout(this.realtimeSyncTimer);
|
||||
this.realtimeSyncTimer = window.setTimeout(() => {
|
||||
this.refreshRemoteConversations();
|
||||
if (this.activeId) {
|
||||
this.scheduleActiveMessagesSync({ allowReadReceipt: canMarkReadNow() }, 700);
|
||||
}
|
||||
}, 500);
|
||||
},
|
||||
bindReadPresenceEvents() {
|
||||
if (this.readPresenceEventsBound || typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return;
|
||||
@@ -800,7 +947,8 @@ export const useConversationStore = defineStore('conversations', {
|
||||
this.readPresenceEventsBound = true;
|
||||
const syncWhenReadable = () => {
|
||||
if (canMarkReadNow()) {
|
||||
this.syncActiveMessages({ allowReadReceipt: true });
|
||||
this.refreshRemoteConversations();
|
||||
this.scheduleActiveMessagesSync({ allowReadReceipt: true }, 300);
|
||||
}
|
||||
};
|
||||
window.addEventListener('focus', syncWhenReadable);
|
||||
@@ -1084,7 +1232,7 @@ export const useConversationStore = defineStore('conversations', {
|
||||
const editedMessage = await imClient.editMessage(conversation, message, text);
|
||||
this.replaceLocalMessage(conversation.id, message, editedMessage);
|
||||
if (messageActivityTimestamp(editedMessage) >= Number(conversation.updatedAt || 0)) {
|
||||
conversation.subtitle = editedMessage.text;
|
||||
conversation.subtitle = messagePreviewText(editedMessage);
|
||||
conversation.updatedAt = messageActivityTimestamp(editedMessage);
|
||||
writeLocalConversations(this.conversations);
|
||||
}
|
||||
@@ -1145,7 +1293,12 @@ export const useConversationStore = defineStore('conversations', {
|
||||
const conversation = await imClient.createGroup(session.userId, options);
|
||||
this.upsertConversation(conversation);
|
||||
this.setActiveConversation(conversation.id);
|
||||
this.setActionNotice(options.channel ? '公告群已创建' : '群组已创建');
|
||||
const nativeStatus = conversation.nativeStatus || conversation.raw?.nativeStatus || '';
|
||||
if (nativeStatus && nativeStatus !== 'ok') {
|
||||
this.setActionNotice(options.channel ? '公告群已创建,仅网页可见' : '群组已创建,仅网页可见');
|
||||
} else {
|
||||
this.setActionNotice(options.channel ? '公告群已创建' : '群组已创建');
|
||||
}
|
||||
this.refreshRemoteConversations();
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -1163,7 +1316,8 @@ export const useConversationStore = defineStore('conversations', {
|
||||
const conversation = await imClient.joinGroup(session.userId, groupKey);
|
||||
this.upsertConversation(conversation);
|
||||
this.setActiveConversation(conversation.id);
|
||||
this.setActionNotice('已加入群组');
|
||||
const nativeStatus = conversation.nativeStatus || conversation.raw?.nativeStatus || '';
|
||||
this.setActionNotice(nativeStatus && nativeStatus !== 'ok' ? '已加入群组,仅网页可见' : '已加入群组');
|
||||
this.refreshRemoteConversations();
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
+124
-4
@@ -1238,6 +1238,14 @@ button {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.empty-list-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.empty-chat {
|
||||
min-height: 0;
|
||||
padding: 24px;
|
||||
@@ -1898,6 +1906,16 @@ button {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.group-member-row .group-member-role {
|
||||
width: fit-content;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid #b8d8d1;
|
||||
border-radius: 5px;
|
||||
background: #edf8f5;
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.group-management-dialog {
|
||||
width: min(520px, calc(100% - 32px));
|
||||
}
|
||||
@@ -1958,7 +1976,9 @@ button {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 24px;
|
||||
padding: 24px 24px 72px;
|
||||
scroll-padding-bottom: 72px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
@@ -2769,9 +2789,11 @@ button {
|
||||
}
|
||||
|
||||
.composer-tools button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
@@ -2782,6 +2804,104 @@ button {
|
||||
background: #edf2f5;
|
||||
}
|
||||
|
||||
.composer-tools button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.composer-attachment-preview {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 16px 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.composer-attachment-item {
|
||||
position: relative;
|
||||
width: 88px;
|
||||
min-width: 88px;
|
||||
height: 88px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #edf2f5;
|
||||
}
|
||||
|
||||
.composer-attachment-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.composer-attachment-file-icon {
|
||||
color: var(--primary);
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.composer-attachment-name {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
padding: 4px 6px;
|
||||
background: rgba(18, 28, 40, 0.72);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.composer-attachment-item > button {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(18, 28, 40, 0.74);
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.composer-tool-button {
|
||||
border-color: rgba(23, 125, 114, 0.28);
|
||||
background: rgba(23, 125, 114, 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.composer-tool-button.file-tool {
|
||||
border-color: rgba(216, 78, 63, 0.28);
|
||||
background: rgba(216, 78, 63, 0.1);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.composer-tool-button:hover {
|
||||
border-color: currentColor;
|
||||
background: rgba(23, 125, 114, 0.16);
|
||||
}
|
||||
|
||||
.composer-tool-button.file-tool:hover {
|
||||
background: rgba(216, 78, 63, 0.16);
|
||||
}
|
||||
|
||||
.composer-tool-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.9;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.emoji-picker {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
|
||||
Reference in New Issue
Block a user