SHA256
Обновить DM, сеть и оффлайн-бандл
This commit is contained in:
@@ -77,16 +77,18 @@ public final class DmDialogStateDAO {
|
|||||||
ps.setString(1, cleanOwner);
|
ps.setString(1, cleanOwner);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
|
long lastMessageTimeMs = rs.getLong("last_message_time_ms");
|
||||||
|
int unreadCount = rs.getInt("unread_count");
|
||||||
DialogSummary row = new DialogSummary(
|
DialogSummary row = new DialogSummary(
|
||||||
rs.getString("owner_login"),
|
rs.getString("owner_login"),
|
||||||
rs.getString("peer_login"),
|
rs.getString("peer_login"),
|
||||||
normalizeRelationFlag(rs.getString("relation_flag")),
|
normalizeRelationFlag(rs.getString("relation_flag")),
|
||||||
rs.getString("last_message_blob_b64"),
|
rs.getString("last_message_blob_b64"),
|
||||||
rs.getLong("last_message_time_ms"),
|
lastMessageTimeMs,
|
||||||
rs.getInt("unread_count"),
|
unreadCount,
|
||||||
rs.getLong("last_read_receipt_time_ms"),
|
rs.getLong("last_read_receipt_time_ms"),
|
||||||
rs.getLong("updated_at_ms"),
|
rs.getLong("updated_at_ms"),
|
||||||
true
|
lastMessageTimeMs > 0 || unreadCount > 0
|
||||||
);
|
);
|
||||||
byPeer.put(normKey(row.peerLogin()), row);
|
byPeer.put(normKey(row.peerLogin()), row);
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+300
@@ -0,0 +1,300 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
# Build an offline-ready source bundle ZIP.
|
||||||
|
# In addition to the normal source tree, this variant can attach a local
|
||||||
|
# Gradle distribution zip and a helper script that rewrites wrapper URLs to
|
||||||
|
# that local file so the bundle can be used without internet access.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./bundle-offline.sh
|
||||||
|
# ./bundle-offline.sh path/to/output.zip
|
||||||
|
#
|
||||||
|
# Expected local asset:
|
||||||
|
# offline/gradle-offline.zip
|
||||||
|
# or a custom path via BUNDLE_OFFLINE_GRADLE_ZIP
|
||||||
|
|
||||||
|
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
OUT="${1:-SHiNE-bundle-offline-$(date +%Y%m%d-%H%M%S).zip}"
|
||||||
|
case "$OUT" in
|
||||||
|
/*) ;;
|
||||||
|
*) OUT="$ROOT/$OUT" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if ! command -v zip >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: 'zip' is required." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
LIST="$TMP/files.txt"
|
||||||
|
SAFE_LIST="$TMP/safe-files.txt"
|
||||||
|
STAGE="$TMP/stage"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
mkdir -p "$STAGE"
|
||||||
|
|
||||||
|
# Paths / filenames that must never be bundled.
|
||||||
|
is_denied_path() {
|
||||||
|
local p="/$1"
|
||||||
|
|
||||||
|
case "$p" in
|
||||||
|
*/.git/*|*/.git|\
|
||||||
|
*/.gradle/*|*/.gradle|\
|
||||||
|
*/.gradle-home/*|*/.gradle-home|\
|
||||||
|
*/.idea/*|*/.idea|\
|
||||||
|
*/.vscode/*|*/.vscode|\
|
||||||
|
*/node_modules/*|*/node_modules|\
|
||||||
|
*/target/*|*/target|\
|
||||||
|
*/build/*|*/build|\
|
||||||
|
*/out/*|*/out|\
|
||||||
|
*/bin/*|*/bin|\
|
||||||
|
*/logs/*|*/logs|\
|
||||||
|
*/data/*|*/data|\
|
||||||
|
*/test-ledger/*|*/test-ledger|\
|
||||||
|
*/.anchor/*|*/.anchor|\
|
||||||
|
*/.yarn/*|*/.yarn|\
|
||||||
|
*/.vendor/*|*/.vendor|\
|
||||||
|
*/.agents/*|*/.agents|\
|
||||||
|
*/.codex/*|*/.codex|\
|
||||||
|
*/.claude/*|*/.claude|\
|
||||||
|
*/deploy/backup/archive/*|\
|
||||||
|
*/scripts/*/runs/*|\
|
||||||
|
*/scripts/*/keypairs/*|\
|
||||||
|
*/keys/*|\
|
||||||
|
*/.git-local-backup/*|\
|
||||||
|
*/SHiNE-bundle-*.zip|\
|
||||||
|
*/bundle-offline*.zip)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
local base="${p##*/}"
|
||||||
|
local lower
|
||||||
|
lower="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
|
||||||
|
case "$lower" in
|
||||||
|
.env|.env.*|\
|
||||||
|
.debug-token|\
|
||||||
|
.npmrc|.pypirc|.netrc|\
|
||||||
|
credentials|credentials.*|\
|
||||||
|
secrets|secrets.*|\
|
||||||
|
secret|secret.*|\
|
||||||
|
id_rsa|id_dsa|id_ecdsa|id_ed25519|\
|
||||||
|
*.pem|*.key|*.p12|*.pfx|*.jks|*.keystore|\
|
||||||
|
*keypair*.json|\
|
||||||
|
service-account*.json|\
|
||||||
|
firebase-adminsdk*.json|\
|
||||||
|
google-services.json|\
|
||||||
|
validator.log)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$lower" in
|
||||||
|
*.class|*.jar|*.war|*.ear|*.o|*.a|*.so|*.dll|*.dylib|\
|
||||||
|
*.elf|*.map|*.uf2|*.bin|*.merged.bin|\
|
||||||
|
*.log|*.bak|*.bak.png|*.tmp|*.swp|*.swo|\
|
||||||
|
.ds_store)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
find_offline_gradle_zip() {
|
||||||
|
local candidate="${BUNDLE_OFFLINE_GRADLE_ZIP:-}"
|
||||||
|
if [[ -n "$candidate" && -f "$candidate" ]]; then
|
||||||
|
printf '%s\n' "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for candidate in \
|
||||||
|
"$ROOT/offline/gradle-offline.zip" \
|
||||||
|
"$ROOT/offline/gradle-8.14-bin.zip" \
|
||||||
|
"$ROOT/offline/gradle.zip"
|
||||||
|
do
|
||||||
|
if [[ -f "$candidate" ]]; then
|
||||||
|
printf '%s\n' "$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
create_offline_helper() {
|
||||||
|
local zip_name="$1"
|
||||||
|
local helper="$STAGE/offline/prepare-local-gradle.sh"
|
||||||
|
local readme="$STAGE/offline/README.txt"
|
||||||
|
|
||||||
|
mkdir -p "$STAGE/offline"
|
||||||
|
|
||||||
|
cat > "$helper" <<EOF
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||||
|
ZIP_PATH="\${1:-\$ROOT/offline/$zip_name}"
|
||||||
|
|
||||||
|
if [[ ! -f "\$ZIP_PATH" ]]; then
|
||||||
|
echo "ERROR: offline Gradle zip not found: \$ZIP_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ABS_ZIP="\$(cd -- "\$(dirname -- "\$ZIP_PATH")" && pwd -P)/\$(basename -- "\$ZIP_PATH")"
|
||||||
|
ESCAPED_ABS_ZIP="\${ABS_ZIP//\\\\/\\\\\\\\}"
|
||||||
|
ESCAPED_ABS_ZIP="\${ESCAPED_ABS_ZIP//&/\\\\&}"
|
||||||
|
ESCAPED_ABS_ZIP="\${ESCAPED_ABS_ZIP//|/\\\\|}"
|
||||||
|
|
||||||
|
while IFS= read -r props; do
|
||||||
|
[[ -f "\$props" ]] || continue
|
||||||
|
cp -p "\$props" "\$props.bak"
|
||||||
|
sed -i -e "s|^distributionUrl=.*\$|distributionUrl=file://\$ESCAPED_ABS_ZIP|" "\$props"
|
||||||
|
done < <(find "\$ROOT" -path '*/gradle/wrapper/gradle-wrapper.properties' -type f | sort)
|
||||||
|
|
||||||
|
cat <<'MSG'
|
||||||
|
Gradle wrapper URLs rewritten to the local offline zip.
|
||||||
|
Run now:
|
||||||
|
./gradlew --offline test
|
||||||
|
MSG
|
||||||
|
EOF
|
||||||
|
chmod +x "$helper"
|
||||||
|
|
||||||
|
cat > "$readme" <<EOF
|
||||||
|
Offline Gradle helper
|
||||||
|
|
||||||
|
Included archive:
|
||||||
|
offline/$zip_name
|
||||||
|
|
||||||
|
Helper:
|
||||||
|
offline/prepare-local-gradle.sh
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
- backs up each gradle-wrapper.properties as .bak
|
||||||
|
- rewrites wrapper distributionUrl to the local zip in this bundle
|
||||||
|
|
||||||
|
Recommended flow after unpacking:
|
||||||
|
1. cd into the unpacked bundle root
|
||||||
|
2. run ./offline/prepare-local-gradle.sh
|
||||||
|
3. run ./gradlew --offline test
|
||||||
|
|
||||||
|
This bundle is intended for local, network-free verification.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Collect files. Prefer Git because it naturally avoids most ignored local files.
|
||||||
|
if command -v git >/dev/null 2>&1 && git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
git -C "$ROOT" ls-files -co --exclude-standard -z > "$TMP/files.z"
|
||||||
|
else
|
||||||
|
find "$ROOT" -type f -print0 > "$TMP/files.z"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Convert to project-relative paths and enforce hard deny rules.
|
||||||
|
: > "$LIST"
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
if [[ "$f" = /* ]]; then
|
||||||
|
rel="${f#"$ROOT"/}"
|
||||||
|
else
|
||||||
|
rel="$f"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ "$rel" == "$OUT" ]] && continue
|
||||||
|
[[ -z "$rel" ]] && continue
|
||||||
|
|
||||||
|
if is_denied_path "$rel"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$rel" >> "$LIST"
|
||||||
|
done < "$TMP/files.z"
|
||||||
|
|
||||||
|
sort -u "$LIST" -o "$LIST"
|
||||||
|
|
||||||
|
# Always include Gradle wrapper bootstrap, even though generic JARs are denied.
|
||||||
|
for wrapper_jar in \
|
||||||
|
'SHiNE-server/gradle/wrapper/gradle-wrapper.jar' \
|
||||||
|
'SHiNE-browser-plugin-wallet/gradle/wrapper/gradle-wrapper.jar'
|
||||||
|
do
|
||||||
|
if [[ -f "$ROOT/$wrapper_jar" ]] && ! grep -Fxq "$wrapper_jar" "$LIST"; then
|
||||||
|
printf '%s\n' "$wrapper_jar" >> "$LIST"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
sort -u "$LIST" -o "$LIST"
|
||||||
|
|
||||||
|
# Content scan: fail closed on common credential/private-key patterns.
|
||||||
|
# We scan only text-ish files; grep -I skips binary data.
|
||||||
|
SECRET_RE='-----BEGIN ([A-Z0-9 ]+ )?PRIVATE KEY-----|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|(^|[^A-Za-z0-9])(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|private[_-]?key)[[:space:]]*[:=][[:space:]]*["'\'']?[^${[:space:]]{][^[:space:]]{7,}'
|
||||||
|
|
||||||
|
: > "$SAFE_LIST"
|
||||||
|
found_secret=0
|
||||||
|
|
||||||
|
while IFS= read -r rel; do
|
||||||
|
[[ -f "$ROOT/$rel" ]] || continue
|
||||||
|
|
||||||
|
# Files that contain examples/templates can legitimately mention secret keys
|
||||||
|
# with placeholders. They are scanned too, but placeholder-looking values
|
||||||
|
# are less likely to match the regex above.
|
||||||
|
if LC_ALL=C grep -IEnq "$SECRET_RE" "$ROOT/$rel" 2>/dev/null; then
|
||||||
|
echo "BLOCKED: possible secret in $rel" >&2
|
||||||
|
LC_ALL=C grep -IEn "$SECRET_RE" "$ROOT/$rel" 2>/dev/null \
|
||||||
|
| sed -E 's/(:[[:space:]]*).*/\1[REDACTED]/' \
|
||||||
|
| head -n 3 >&2 || true
|
||||||
|
found_secret=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$rel" >> "$SAFE_LIST"
|
||||||
|
done < "$LIST"
|
||||||
|
|
||||||
|
if (( found_secret != 0 )); then
|
||||||
|
echo >&2
|
||||||
|
echo "Bundle NOT created because possible secrets were detected." >&2
|
||||||
|
echo "Move secrets to ignored/local files or adjust the scanner only after review." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -s "$SAFE_LIST" ]]; then
|
||||||
|
echo "ERROR: no files left to bundle." >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
OFFLINE_ZIP_SRC=""
|
||||||
|
OFFLINE_ZIP_NAME=""
|
||||||
|
if OFFLINE_ZIP_SRC="$(find_offline_gradle_zip)"; then
|
||||||
|
OFFLINE_ZIP_NAME="gradle-offline.zip"
|
||||||
|
else
|
||||||
|
echo "ERROR: offline Gradle zip not found." >&2
|
||||||
|
echo "Place it at ./offline/gradle-offline.zip or set BUNDLE_OFFLINE_GRADLE_ZIP." >&2
|
||||||
|
echo "The bundle is not created because this variant is meant to be offline-ready." >&2
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$STAGE"
|
||||||
|
mkdir -p "$STAGE"
|
||||||
|
|
||||||
|
while IFS= read -r rel; do
|
||||||
|
src="$ROOT/$rel"
|
||||||
|
dst="$STAGE/$rel"
|
||||||
|
mkdir -p "$(dirname -- "$dst")"
|
||||||
|
cp -p "$src" "$dst"
|
||||||
|
done < "$SAFE_LIST"
|
||||||
|
|
||||||
|
mkdir -p "$STAGE/offline"
|
||||||
|
cp -p "$OFFLINE_ZIP_SRC" "$STAGE/offline/$OFFLINE_ZIP_NAME"
|
||||||
|
create_offline_helper "$OFFLINE_ZIP_NAME"
|
||||||
|
|
||||||
|
rm -f -- "$OUT"
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "$STAGE"
|
||||||
|
find . -type f -print | sort | zip -q -9 "$OUT" -@
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "Created: $OUT"
|
||||||
|
echo "Files: $(cd "$STAGE" && find . -type f | wc -l | tr -d ' ')"
|
||||||
|
echo "Size: $(du -h "$OUT" | awk '{print $1}')"
|
||||||
@@ -203,3 +203,15 @@ DeleteMessage принимает type=5/6. DeleteConversation принимает
|
|||||||
- Доступные группы: «Все чаты», «Близкие друзья», «Контакты», «Новые».
|
- Доступные группы: «Все чаты», «Близкие друзья», «Контакты», «Новые».
|
||||||
- Фильтрация использует уже существующий признак отношения диалога: `close_friend`, `contact`, `none`; формат DM и серверный протокол при этом не меняются.
|
- Фильтрация использует уже существующий признак отношения диалога: `close_friend`, `contact`, `none`; формат DM и серверный протокол при этом не меняются.
|
||||||
- Для недавних сообщений UI показывает относительное время (секунды, минуты, часы, дни). Для более старых сообщений текущего года используется формат `DD.MM, HH:MM`, для другого года — `DD.MM.YYYY` без времени.
|
- Для недавних сообщений UI показывает относительное время (секунды, минуты, часы, дни). Для более старых сообщений текущего года используется формат `DD.MM, HH:MM`, для другого года — `DD.MM.YYYY` без времени.
|
||||||
|
|
||||||
|
## UI: видимость пустого диалога после DeleteConversation
|
||||||
|
|
||||||
|
`DeleteConversation` (`type=7/8`) остаётся техническим tombstone и сам по себе не считается пользовательским сообщением диалога.
|
||||||
|
|
||||||
|
Для списка личных чатов действует правило:
|
||||||
|
|
||||||
|
- если после очистки истории у пары нет обычных DM-сообщений и пользователь не находится в `contact`, `friend` или `close_friend`, строка диалога не показывается;
|
||||||
|
- если связь `contact`, `friend` или `close_friend` сохраняется, пустой чат может оставаться в списке как чат существующей связи;
|
||||||
|
- при удалении чата с `friend`/`close_friend` UI должен отдельно предупредить, что одна очистка истории не уберёт строку чата, и при подтверждении снять социальную связь и очистить историю.
|
||||||
|
|
||||||
|
Это правило не меняет wire/API-формат DM и не меняет байтовый формат tombstone.
|
||||||
|
|||||||
@@ -356,3 +356,9 @@ ReadReceiptBody_v1_0
|
|||||||
|
|
||||||
## Примечание UI списка чатов (2026-08-28)
|
## Примечание UI списка чатов (2026-08-28)
|
||||||
Это изменение не меняет байтовый формат DM. В списке чатов клиент может фильтровать уже полученные диалоги по `relationFlag` (`close_friend`, `contact`, `none`) и локально форматировать время последнего сообщения: относительное для недавних, `DD.MM, HH:MM` в текущем году и `DD.MM.YYYY` для прошлых лет.
|
Это изменение не меняет байтовый формат DM. В списке чатов клиент может фильтровать уже полученные диалоги по `relationFlag` (`close_friend`, `contact`, `none`) и локально форматировать время последнего сообщения: относительное для недавних, `DD.MM, HH:MM` в текущем году и `DD.MM.YYYY` для прошлых лет.
|
||||||
|
|
||||||
|
## UI-семантика `type=7/8` в списке диалогов
|
||||||
|
|
||||||
|
Контейнер `type=7/8` является служебным tombstone очистки истории. Его наличие без обычных сообщений `type=1/2` не должно само по себе означать, что у пары есть видимый пользовательский диалог.
|
||||||
|
|
||||||
|
Следствие для UI/агрегата диалогов: `hasDialog` определяется наличием пользовательского содержимого (или непрочитанных пользовательских сообщений), а не наличием служебной записи состояния/tombstone. Формат контейнера при этом не изменяется.
|
||||||
|
|||||||
@@ -264,18 +264,30 @@ function openChatConfirmModal({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDeleteChatConfirmModal({ contactName = '', onConfirm }) {
|
function openDeleteChatConfirmModal({ contactName = '', relationType = 'none', onConfirm }) {
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
|
const relation = normalizeChatRelationType(relationType);
|
||||||
|
const isCloseFriend = relation === 'close_friend';
|
||||||
|
const isFriend = relation === 'friend';
|
||||||
|
const isProtectedRelation = isCloseFriend || isFriend;
|
||||||
|
const relationName = isCloseFriend ? 'близких друзей' : 'друзей';
|
||||||
|
const safeName = String(contactName || '').trim() || 'этого пользователя';
|
||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="modal" id="chat-delete-chat-modal">
|
<div class="modal" id="chat-delete-chat-modal">
|
||||||
<div class="modal-card stack dm-dialog-card">
|
<div class="modal-card stack dm-dialog-card">
|
||||||
<h3 class="modal-title">Удалить чат?</h3>
|
<h3 class="modal-title">Удалить чат?</h3>
|
||||||
<p class="meta-muted">Удалить пользователя ${contactName} из контактов?</p>
|
${isProtectedRelation ? `
|
||||||
|
<p class="meta-muted">Можно удалить содержимое переписки, но чат с ${isCloseFriend ? 'близким другом' : 'другом'} останется в списке.</p>
|
||||||
|
<p class="meta-muted">Удалить ${safeName} из ${relationName} и удалить чат?</p>
|
||||||
|
` : `
|
||||||
|
<p class="meta-muted">Удалить пользователя ${safeName} из контактов?</p>
|
||||||
<label class="dm-confirm-check">
|
<label class="dm-confirm-check">
|
||||||
<input type="checkbox" id="chat-delete-chat-history" checked />
|
<input type="checkbox" id="chat-delete-chat-history" checked />
|
||||||
<span>Также удалить всю историю переписки</span>
|
<span>Также удалить всю историю переписки</span>
|
||||||
</label>
|
</label>
|
||||||
|
`}
|
||||||
<div class="form-actions-grid">
|
<div class="form-actions-grid">
|
||||||
<button class="secondary-btn" type="button" id="chat-delete-chat-no">Нет</button>
|
<button class="secondary-btn" type="button" id="chat-delete-chat-no">Нет</button>
|
||||||
<button class="destructive-btn" type="button" id="chat-delete-chat-yes">Да</button>
|
<button class="destructive-btn" type="button" id="chat-delete-chat-yes">Да</button>
|
||||||
@@ -290,10 +302,12 @@ function openDeleteChatConfirmModal({ contactName = '', onConfirm }) {
|
|||||||
|
|
||||||
root.querySelector('#chat-delete-chat-no')?.addEventListener('click', close);
|
root.querySelector('#chat-delete-chat-no')?.addEventListener('click', close);
|
||||||
root.querySelector('#chat-delete-chat-yes')?.addEventListener('click', async () => {
|
root.querySelector('#chat-delete-chat-yes')?.addEventListener('click', async () => {
|
||||||
const deleteHistory = Boolean(root.querySelector('#chat-delete-chat-history')?.checked);
|
const deleteHistory = isProtectedRelation
|
||||||
|
? true
|
||||||
|
: Boolean(root.querySelector('#chat-delete-chat-history')?.checked);
|
||||||
close();
|
close();
|
||||||
if (typeof onConfirm === 'function') {
|
if (typeof onConfirm === 'function') {
|
||||||
await onConfirm({ deleteHistory });
|
await onConfirm({ deleteHistory, removeRelation: isProtectedRelation });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1173,24 +1187,40 @@ export function render({ navigate, route, chrome }) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDeleteChat: async () => {
|
onDeleteChat: async () => {
|
||||||
|
const relationBeforeDelete = normalizeChatRelationType(peerRelationType);
|
||||||
openDeleteChatConfirmModal({
|
openDeleteChatConfirmModal({
|
||||||
contactName: contact.name,
|
contactName: contact.name,
|
||||||
|
relationType: relationBeforeDelete,
|
||||||
onConfirm: async ({ deleteHistory }) => {
|
onConfirm: async ({ deleteHistory }) => {
|
||||||
try {
|
try {
|
||||||
if (deleteHistory) {
|
if (deleteHistory) {
|
||||||
await clearConversationHistory();
|
await clearConversationHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Друг/близкий друг может иметь одновременно и более слабые флаги связи.
|
||||||
|
// Для настоящего «удалить чат» снимаем весь социальный стек, иначе пустой чат
|
||||||
|
// закономерно останется в списке из-за действующей связи.
|
||||||
|
const relationKinds = relationBeforeDelete === 'close_friend' || relationBeforeDelete === 'friend'
|
||||||
|
? ['close_friend', 'friend', 'contact']
|
||||||
|
: ['contact'];
|
||||||
|
for (const kind of relationKinds) {
|
||||||
await authService.setUserRelation({
|
await authService.setUserRelation({
|
||||||
login: state.session.login,
|
login: state.session.login,
|
||||||
toLogin: chatId,
|
toLogin: chatId,
|
||||||
kind: 'contact',
|
kind,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
storagePwd: state.session.storagePwdInMemory,
|
storagePwd: state.session.storagePwdInMemory,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const contactsPayload = await authService.listContacts();
|
const contactsPayload = await authService.listContacts();
|
||||||
setContacts(contactsPayload?.contacts || []);
|
setContacts(
|
||||||
|
contactsPayload?.contacts
|
||||||
|
|| contactsPayload?.dialogs?.filter((x) => x?.relationFlag !== 'none').map((x) => x.peerLogin)
|
||||||
|
|| [],
|
||||||
|
);
|
||||||
notifyUnreadStateUpdated();
|
notifyUnreadStateUpdated();
|
||||||
showToast('Чат удалён из контактов', { timeoutMs: 1200 });
|
showToast('Чат удалён', { timeoutMs: 1200 });
|
||||||
navigate('messages-list');
|
navigate('messages-list');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
|
||||||
|
|||||||
@@ -448,6 +448,9 @@ function renderRow(item) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const rows = Array.from(byPeer.values())
|
const rows = Array.from(byPeer.values())
|
||||||
|
// Технический tombstone очистки истории сам по себе не создаёт видимый диалог.
|
||||||
|
// Пустые друзья/контакты остаются, а пользователь без связи исчезает после очистки.
|
||||||
|
.filter((item) => normalizeRelationFlag(item.relationFlag) !== 'none' || Boolean(item.hasDialog))
|
||||||
.filter((item) => currentChatFilter === 'all' || normalizeRelationFlag(item.relationFlag) === currentChatFilter)
|
.filter((item) => currentChatFilter === 'all' || normalizeRelationFlag(item.relationFlag) === currentChatFilter)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const orderA = relationOrder(a.relationFlag);
|
const orderA = relationOrder(a.relationFlag);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
|
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||||
|
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||||
import { authService, state } from '../state.js';
|
import { authService, state } from '../state.js';
|
||||||
import { makeProfileRoute } from '../services/shine-routes.js';
|
import { makeProfileRoute } from '../services/shine-routes.js';
|
||||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||||
@@ -25,32 +27,6 @@ function createDebounced(fn, delayMs = 2000) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHeaderSearchIcon() {
|
|
||||||
const ns = 'http://www.w3.org/2000/svg';
|
|
||||||
const svg = document.createElementNS(ns, 'svg');
|
|
||||||
svg.setAttribute('viewBox', '0 0 24 24');
|
|
||||||
svg.setAttribute('aria-hidden', 'true');
|
|
||||||
svg.setAttribute('class', 'header-icon-svg header-icon-svg--search');
|
|
||||||
|
|
||||||
const circle = document.createElementNS(ns, 'circle');
|
|
||||||
circle.setAttribute('cx', '11');
|
|
||||||
circle.setAttribute('cy', '11');
|
|
||||||
circle.setAttribute('r', '6.5');
|
|
||||||
circle.setAttribute('fill', 'none');
|
|
||||||
circle.setAttribute('stroke', 'currentColor');
|
|
||||||
circle.setAttribute('stroke-width', '2');
|
|
||||||
|
|
||||||
const handle = document.createElementNS(ns, 'path');
|
|
||||||
handle.setAttribute('d', 'M16 16l4.5 4.5');
|
|
||||||
handle.setAttribute('fill', 'none');
|
|
||||||
handle.setAttribute('stroke', 'currentColor');
|
|
||||||
handle.setAttribute('stroke-width', '2');
|
|
||||||
handle.setAttribute('stroke-linecap', 'round');
|
|
||||||
|
|
||||||
svg.append(circle, handle);
|
|
||||||
return svg;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normKey(value) {
|
function normKey(value) {
|
||||||
return normalizeLogin(value).toLowerCase();
|
return normalizeLogin(value).toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -290,7 +266,7 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
<div class="modal" id="network-search-modal">
|
<div class="modal" id="network-search-modal">
|
||||||
<div class="modal-card stack">
|
<div class="modal-card stack">
|
||||||
<button class="icon-btn" type="button" id="network-search-close" style="justify-self:end;">✕</button>
|
<button class="icon-btn" type="button" id="network-search-close" style="justify-self:end;">✕</button>
|
||||||
<h3 class="modal-title">Найти человека</h3>
|
<h3 class="modal-title">Найти пользователя</h3>
|
||||||
<div class="row" style="gap:8px;">
|
<div class="row" style="gap:8px;">
|
||||||
<input class="input" id="network-search-input" type="text" maxlength="30" placeholder="Введите логин" />
|
<input class="input" id="network-search-input" type="text" maxlength="30" placeholder="Введите логин" />
|
||||||
<button class="primary-btn" type="button" id="network-search-run">Искать</button>
|
<button class="primary-btn" type="button" id="network-search-run">Искать</button>
|
||||||
@@ -462,17 +438,32 @@ export function render({ navigate, route, chrome } = {}) {
|
|||||||
title: 'Связи',
|
title: 'Связи',
|
||||||
rightActions: [
|
rightActions: [
|
||||||
{
|
{
|
||||||
iconNode: createHeaderSearchIcon(),
|
iconNode: createOverflowDots(),
|
||||||
title: 'Найти пользователя',
|
title: 'Меню связей',
|
||||||
ariaLabel: 'Найти пользователя',
|
ariaLabel: 'Открыть меню связей',
|
||||||
className: 'chat-header-icon-btn',
|
className: 'chat-header-icon-btn network-header-menu-btn',
|
||||||
onClick: openSearchModal,
|
onClick: () => {},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
const networkMenuButton = header.querySelector('.network-header-menu-btn');
|
||||||
|
const searchIconHtml = `
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="11" cy="11" r="6.5"></circle>
|
||||||
|
<path d="M16 16l4 4"></path>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
const networkMenu = createDropdownMenu({
|
||||||
|
anchorEl: networkMenuButton,
|
||||||
|
minWidth: 220,
|
||||||
|
items: [
|
||||||
|
{ label: 'Найти пользователя', iconHtml: searchIconHtml, action: openSearchModal },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
|
networkMenu.destroy();
|
||||||
if (engine) engine.destroy();
|
if (engine) engine.destroy();
|
||||||
engine = null;
|
engine = null;
|
||||||
appScreenEl?.classList.remove('network-scroll-lock');
|
appScreenEl?.classList.remove('network-scroll-lock');
|
||||||
|
|||||||
@@ -242,10 +242,9 @@
|
|||||||
.fg-node.is-pressed .node-dot { transform: none; }
|
.fg-node.is-pressed .node-dot { transform: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* «Сияние» — мягкое живое свечение НА УЗЛЕ (аватарке), а не на линии связи.
|
/* «Сияние» — постоянное живое свечение НА УЗЛЕ (аватарке), а не на линии связи.
|
||||||
Многослойная анимированная box-shadow + размытый радиальный ореол (через внешний SVG-фильтр).
|
Пульсация остаётся мягкой, но нижняя точка теперь не проваливается почти в ноль:
|
||||||
Пульсация очень медленная и плавная (3.6с): радиус и прозрачность «дышат» 0.5 ↔ 1.0 —
|
визуально сияющий пользователь всегда остаётся явно сияющим. */
|
||||||
как мягкое свечение живого организма в темноте, а не «жирный маркер». */
|
|
||||||
.fg-node.is-shine .node-dot {
|
.fg-node.is-shine .node-dot {
|
||||||
border-color: rgba(150, 240, 255, 0.62);
|
border-color: rgba(150, 240, 255, 0.62);
|
||||||
animation: fg-shine-glow 3.6s ease-in-out infinite;
|
animation: fg-shine-glow 3.6s ease-in-out infinite;
|
||||||
@@ -268,9 +267,9 @@
|
|||||||
@keyframes fg-shine-glow {
|
@keyframes fg-shine-glow {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 5px rgba(125, 232, 255, 0.30),
|
0 0 7px rgba(138, 239, 255, 0.48),
|
||||||
0 0 11px rgba(112, 226, 255, 0.18),
|
0 0 15px rgba(118, 232, 255, 0.32),
|
||||||
0 0 20px rgba(100, 220, 255, 0.10);
|
0 0 27px rgba(100, 220, 255, 0.19);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@@ -282,7 +281,7 @@
|
|||||||
|
|
||||||
/* ореол дышит размером и прозрачностью синхронно с тенью (мягко, без рывков) */
|
/* ореол дышит размером и прозрачностью синхронно с тенью (мягко, без рывков) */
|
||||||
@keyframes fg-shine-halo {
|
@keyframes fg-shine-halo {
|
||||||
0%, 100% { transform: scale(0.9); opacity: 0.5; }
|
0%, 100% { transform: scale(0.98); opacity: 0.72; }
|
||||||
50% { transform: scale(1.12); opacity: 1; }
|
50% { transform: scale(1.12); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user