Аватары: общий компонент, кэш txId и avatar.ar в графе связей

This commit is contained in:
AidarKC 2026-04-26 02:27:41 +03:00
parent 667c5310bf
commit 4c1aeeeac8
13 changed files with 392 additions and 103 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.5
server.version=1.2.5
client.version=1.2.6
server.version=1.2.6

View File

@ -6,8 +6,8 @@
<link rel="manifest" href="./manifest.webmanifest" />
<title>Shine UI Demo</title>
<script>
window.__SHINE_BUILD_HASH__ = '20260426025000';
window.__SHINE_CLIENT_VERSION__ = '1.2.5';
window.__SHINE_BUILD_HASH__ = '20260426102000';
window.__SHINE_CLIENT_VERSION__ = '1.2.6';
</script>
<script>
(function attachStylesWithBuildHash() {

View File

@ -0,0 +1,88 @@
import { state } from '../state.js';
import { validateArweaveTxId } from '../services/arweave-file-service.js';
import { getCachedAvatarObjectUrl } from '../services/arweave-avatar-cache-service.js';
function normalizeLogin(value) {
return String(value || '').trim();
}
function pickSizeClass(size) {
const raw = String(size || '').trim().toLowerCase();
if (raw === 'large') return 'large';
if (raw === 'node') return 'node-dot';
if (raw === 'small') return '';
return raw || '';
}
export function buildAvatarInitials({ login, firstName = '', lastName = '' } = {}) {
const first = String(firstName || '').trim();
const last = String(lastName || '').trim();
if (first || last) {
const initials = `${(first[0] || '').toUpperCase()}${(last[0] || '').toUpperCase()}`.trim();
if (initials) return initials;
}
const cleanLogin = normalizeLogin(login);
return (cleanLogin[0] || '?').toUpperCase();
}
export function renderUserAvatar({
login,
firstName = '',
lastName = '',
avatar = null,
size = 'large',
className = '',
title = '',
} = {}) {
const wrap = document.createElement('div');
const classes = ['avatar', 'avatar-image'];
const sizeClass = pickSizeClass(size);
if (sizeClass) classes.push(sizeClass);
const extraClass = String(className || '').trim();
if (extraClass) classes.push(...extraClass.split(/\s+/g));
wrap.className = classes.join(' ');
if (title) wrap.title = String(title);
const fallback = document.createElement('span');
fallback.className = 'avatar-fallback';
fallback.textContent = buildAvatarInitials({ login, firstName, lastName });
wrap.append(fallback);
const txId = String(avatar?.ar || '').trim();
if (!validateArweaveTxId(txId)) {
return wrap;
}
const img = document.createElement('img');
img.alt = 'Аватар';
img.loading = 'lazy';
img.decoding = 'async';
img.hidden = true;
wrap.append(img);
const gateway = state?.entrySettings?.arweaveServer;
void getCachedAvatarObjectUrl({ gateway, txId })
.then((objectUrl) => {
img.onload = () => {
fallback.hidden = true;
img.hidden = false;
if (objectUrl.startsWith('blob:')) {
URL.revokeObjectURL(objectUrl);
}
};
img.onerror = () => {
img.hidden = true;
fallback.hidden = false;
if (objectUrl.startsWith('blob:')) {
URL.revokeObjectURL(objectUrl);
}
};
img.src = objectUrl;
})
.catch(() => {
img.hidden = true;
fallback.hidden = false;
});
return wrap;
}

View File

@ -334,7 +334,7 @@ export function openAvatarWizard({
jwk: walletCtx?.jwk,
file: optimized.file,
tags: [
{ name: 'SHiNE-Login', value: cleanLogin },
{ name: 'SHiNE-Profile-Login', value: cleanLogin },
],
});
uploadedTxId = String(uploaded.id || '').trim();

View File

@ -1,5 +1,6 @@
import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import { renderUserAvatar } from '../components/avatar-image.js';
export const pageMeta = { id: 'network-view', title: 'Связи' };
@ -56,11 +57,20 @@ function getMarkByLogin(allUsers) {
shine: Boolean(row?.shine),
officialLabel: String(row?.officialLabel || (row?.official ? 'официальный' : 'неофициальный')),
shineLabel: String(row?.shineLabel || (row?.shine ? 'сияющий' : 'несияющий')),
avatar: normalizeAvatar(row),
});
});
return map;
}
function normalizeAvatar(row) {
const txFromAvatar = String(row?.avatar?.ar || '').trim();
if (txFromAvatar) return { ar: txFromAvatar };
const txFallback = String(row?.avatarTxId || '').trim();
if (txFallback) return { ar: txFallback };
return null;
}
function applyRelativeGender(map, rows) {
(Array.isArray(rows) ? rows : []).forEach((row) => {
const login = normalizeLogin(row?.login);
@ -108,12 +118,23 @@ function buildNodeElement({ login, kind = 'friend', isCenter = false, mark = nul
const metaText = metaParts.join(', ');
node.title = metaText ? `${login}\n${metaText}` : login;
const officialBadge = mark?.official ? '<span class="node-badge-official" aria-hidden="true">ОФ</span>' : '';
node.innerHTML = `
${officialBadge}
<span class="node-dot">${(login[0] || '?').toUpperCase()}</span>
<span class="node-label">${login}</span>
`;
if (mark?.official) {
const officialBadge = document.createElement('span');
officialBadge.className = 'node-badge-official';
officialBadge.setAttribute('aria-hidden', 'true');
officialBadge.textContent = 'ОФ';
node.append(officialBadge);
}
node.append(renderUserAvatar({
login,
avatar: mark?.avatar || null,
size: 'node',
title: login,
}));
const label = document.createElement('span');
label.className = 'node-label';
label.textContent = login;
node.append(label);
return node;
}

View File

@ -11,8 +11,8 @@ import {
saveProfileToggle,
} from '../services/user-profile-params.js';
import { buildIdentityLines, loadUserProfileCard } from '../services/user-connections.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
import { openAvatarWizard } from '../components/avatar-wizard.js';
import { renderUserAvatar } from '../components/avatar-image.js';
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
@ -105,10 +105,7 @@ export function render({ navigate }) {
topRow.innerHTML = `
<div class="row" style="gap:12px; align-items:center;">
<div class="profile-avatar-block">
<div class="avatar large profile-avatar" data-profile-avatar="true">
<span data-avatar-fallback="true">${profile.avatarInitials}</span>
<img src="" alt="Аватар" data-avatar-image="true" hidden />
</div>
<div data-profile-avatar-slot="true"></div>
<button class="ghost-btn profile-avatar-change-btn" type="button" data-change-avatar="true">Сменить аватар</button>
</div>
<div class="profile-identity-lines" data-profile-identity="true">
@ -154,8 +151,7 @@ export function render({ navigate }) {
let currentGender = PROFILE_GENDER_UNKNOWN;
let currentAvatar = { value: '', source: '', txId: '', timeMs: 0 };
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
const avatarImageEl = topRow.querySelector('[data-avatar-image="true"]');
const avatarFallbackEl = topRow.querySelector('[data-avatar-fallback="true"]');
const avatarSlotEl = topRow.querySelector('[data-profile-avatar-slot="true"]');
function openGenderPickerModal(initialGender) {
const root = document.getElementById('modal-root');
@ -219,56 +215,19 @@ export function render({ navigate }) {
)).join('');
}
function buildAvatarFallbackText() {
function updateAvatarUi() {
if (!(avatarSlotEl instanceof HTMLElement)) return;
const firstName = String(currentFields.find((field) => field.key === 'first_name')?.value || '').trim();
const lastName = String(currentFields.find((field) => field.key === 'last_name')?.value || '').trim();
const fromNames = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
if (fromNames.trim()) return fromNames;
const loginFirst = String(login || '').trim().charAt(0).toUpperCase();
if (loginFirst) return loginFirst;
return String(profile.avatarInitials || '??').trim().slice(0, 2).toUpperCase();
}
function showAvatarFallback() {
if (avatarImageEl instanceof HTMLImageElement) {
avatarImageEl.hidden = true;
avatarImageEl.removeAttribute('src');
}
if (avatarFallbackEl instanceof HTMLElement) {
avatarFallbackEl.hidden = false;
avatarFallbackEl.textContent = buildAvatarFallbackText();
}
}
function updateAvatarUi() {
if (!(avatarImageEl instanceof HTMLImageElement)) return;
const txId = String(currentAvatar?.txId || '').trim();
if (!txId) {
showAvatarFallback();
return;
}
let avatarUrl = '';
try {
avatarUrl = buildArweaveDataUrl({
gateway: state.entrySettings.arweaveServer,
txId,
});
} catch {
showAvatarFallback();
return;
}
avatarImageEl.onload = () => {
if (avatarFallbackEl instanceof HTMLElement) avatarFallbackEl.hidden = true;
avatarImageEl.hidden = false;
};
avatarImageEl.onerror = () => {
showAvatarFallback();
};
avatarImageEl.src = avatarUrl;
avatarSlotEl.innerHTML = '';
avatarSlotEl.append(renderUserAvatar({
login,
firstName,
lastName,
avatar: currentAvatar?.txId ? { ar: currentAvatar.txId } : null,
size: 'large',
className: 'profile-avatar',
}));
}
function updateToggleButton(button, prefix, enabled) {
@ -846,7 +805,7 @@ export function render({ navigate }) {
card.append(topRow, badgesRow, status, listWrap, relativesCard);
screen.append(card);
showAvatarFallback();
updateAvatarUi();
refreshProfileSnapshot();
return screen;

View File

@ -1,11 +1,11 @@
import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import {
buildAvatarInitials,
buildIdentityLines,
loadRelationsForPair,
loadUserProfileCard,
} from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js';
export const pageMeta = { id: 'user-profile-view', title: 'Чужой профиль' };
@ -54,16 +54,31 @@ function renderIdentity(card) {
lastName: card.lastName,
});
return `
<div class="row" style="gap:12px; align-items:center;">
<div class="avatar large">${escapeHtml(buildAvatarInitials(card))}</div>
<div class="profile-identity-lines">
${lines.map((line, idx) => (
`<div class="profile-identity-line${idx === lines.length - 1 ? ' profile-identity-login' : ''}">${escapeHtml(line)}</div>`
)).join('')}
</div>
</div>
`;
const row = document.createElement('div');
row.className = 'row';
row.style.gap = '12px';
row.style.alignItems = 'center';
row.append(renderUserAvatar({
login: card.login,
firstName: card.firstName,
lastName: card.lastName,
avatar: card.avatar,
size: 'large',
className: 'profile-avatar',
}));
const identityLines = document.createElement('div');
identityLines.className = 'profile-identity-lines';
lines.forEach((line, idx) => {
const lineEl = document.createElement('div');
lineEl.className = `profile-identity-line${idx === lines.length - 1 ? ' profile-identity-login' : ''}`;
lineEl.textContent = line;
identityLines.append(lineEl);
});
row.append(identityLines);
return row;
}
function renderReadOnlyBadges(card) {
@ -174,9 +189,6 @@ export function render({ navigate, route }) {
currentFlags = flags;
body.innerHTML = `
<div class="card stack">
${renderIdentity(card)}
</div>
${renderReadOnlyBadges(card)}
${renderRelations(flags)}
${renderReadOnlyParams(card)}
@ -186,6 +198,10 @@ export function render({ navigate, route }) {
<button class="ghost-btn" type="button" data-relation-action="contact"></button>
</div>
`;
const identityCard = document.createElement('div');
identityCard.className = 'card stack';
identityCard.append(renderIdentity(card));
body.prepend(identityCard);
syncActionButtons();
status.className = 'status-line is-available';

View File

@ -0,0 +1,156 @@
import { buildArweaveDataUrl, validateArweaveTxId } from './arweave-file-service.js';
const DB_NAME = 'shine-ui-avatar-cache';
const DB_VERSION = 1;
const STORE = 'avatars';
const MAX_ITEMS = 200;
const MAX_BYTES = 50 * 1024 * 1024;
function openDb() {
return new Promise((resolve, reject) => {
if (typeof indexedDB === 'undefined') {
reject(new Error('IndexedDB недоступен'));
return;
}
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: 'txId' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error('Ошибка открытия IndexedDB'));
});
}
async function withStore(mode, runner) {
const db = await openDb();
try {
return await new Promise((resolve, reject) => {
const tx = db.transaction(STORE, mode);
const store = tx.objectStore(STORE);
const result = runner(store, tx, resolve, reject);
if (result !== undefined) resolve(result);
tx.onerror = () => reject(tx.error || new Error('Ошибка транзакции IndexedDB'));
});
} finally {
db.close();
}
}
async function getRecord(txId) {
return withStore('readonly', (store, _tx, resolve, reject) => {
const req = store.get(txId);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error || new Error('Ошибка чтения кэша аватарки'));
});
}
async function putRecord(record) {
return withStore('readwrite', (store, tx, resolve, reject) => {
store.put(record);
tx.oncomplete = () => resolve(true);
tx.onerror = () => reject(tx.error || new Error('Ошибка записи кэша аватарки'));
});
}
async function getAllRecords() {
return withStore('readonly', (store, _tx, resolve, reject) => {
const req = store.getAll();
req.onsuccess = () => resolve(Array.isArray(req.result) ? req.result : []);
req.onerror = () => reject(req.error || new Error('Ошибка чтения списка кэша аватарок'));
});
}
async function deleteRecords(keys) {
if (!Array.isArray(keys) || !keys.length) return;
await withStore('readwrite', (store, tx, resolve, reject) => {
keys.forEach((key) => store.delete(key));
tx.oncomplete = () => resolve(true);
tx.onerror = () => reject(tx.error || new Error('Ошибка очистки кэша аватарок'));
});
}
async function ensureCacheLimits() {
const records = await getAllRecords();
if (!records.length) return;
let totalBytes = 0;
records.forEach((row) => {
totalBytes += Number(row?.sizeBytes || row?.blob?.size || 0);
});
if (records.length <= MAX_ITEMS && totalBytes <= MAX_BYTES) return;
const ordered = [...records].sort((a, b) => Number(a?.cachedAtMs || 0) - Number(b?.cachedAtMs || 0));
const deleteKeys = [];
let keepCount = records.length;
let keepBytes = totalBytes;
for (let i = 0; i < ordered.length; i += 1) {
if (keepCount <= MAX_ITEMS && keepBytes <= MAX_BYTES) break;
const row = ordered[i];
deleteKeys.push(String(row?.txId || ''));
keepCount -= 1;
keepBytes -= Number(row?.sizeBytes || row?.blob?.size || 0);
}
await deleteRecords(deleteKeys.filter(Boolean));
}
async function fetchAvatarBlob({ gateway, txId }) {
const url = buildArweaveDataUrl({ gateway, txId });
const response = await fetch(url, { method: 'GET' });
if (!response.ok) {
throw new Error(`Не удалось загрузить аватар (${response.status} ${response.statusText})`);
}
const blob = await response.blob();
const type = String(blob?.type || '').toLowerCase();
if (!type.startsWith('image/')) {
throw new Error('Arweave-файл не является изображением');
}
return blob;
}
async function getBlobFromCacheOrGateway({ gateway, txId }) {
try {
const cached = await getRecord(txId);
if (cached?.blob instanceof Blob) {
return cached.blob;
}
} catch {
// ignore IndexedDB errors and fallback to fetch
}
const blob = await fetchAvatarBlob({ gateway, txId });
const record = {
txId,
blob,
contentType: String(blob.type || 'application/octet-stream'),
sizeBytes: Number(blob.size || 0),
cachedAtMs: Date.now(),
};
try {
await putRecord(record);
await ensureCacheLimits();
} catch {
// ignore cache write errors
}
return blob;
}
export async function getCachedAvatarObjectUrl({ gateway, txId }) {
const cleanTxId = String(txId || '').trim();
if (!validateArweaveTxId(cleanTxId)) {
throw new Error('Некорректный Transaction ID Arweave');
}
const blob = await getBlobFromCacheOrGateway({ gateway, txId: cleanTxId });
return URL.createObjectURL(blob);
}
export async function clearAvatarCache() {
await withStore('readwrite', (store, tx, resolve, reject) => {
store.clear();
tx.oncomplete = () => resolve(true);
tx.onerror = () => reject(tx.error || new Error('Ошибка очистки кэша аватарок'));
});
}

View File

@ -249,14 +249,13 @@ export async function uploadArweaveFile({ gateway, jwk, file, tags = [] }) {
tx.addTag('Content-Type', String(file.type || 'application/octet-stream'));
tx.addTag('App-Name', 'SHiNE');
tx.addTag('SHiNE-Type', 'avatar');
const extraTags = Array.isArray(tags) ? tags : [];
extraTags.forEach((item) => {
const name = String(item?.name || '').trim();
const value = String(item?.value || '').trim();
if (!name || !value) return;
tx.addTag(name, value);
});
const profileLoginTag = extraTags.find((item) => String(item?.name || '').trim() === 'SHiNE-Profile-Login');
const profileLogin = String(profileLoginTag?.value || '').trim();
if (!profileLogin) {
throw new Error('Не указан логин профиля для тега SHiNE-Profile-Login');
}
tx.addTag('SHiNE-Profile-Login', profileLogin);
await arweave.transactions.sign(tx, jwk);
const postResult = await arweave.transactions.post(tx);

View File

@ -1,5 +1,6 @@
import { authService, state } from '../state.js';
import { loadProfileSnapshot } from './user-profile-params.js';
import { buildAvatarInitials as buildAvatarInitialsFromComponent } from '../components/avatar-image.js';
function normalizeLogin(value) {
return String(value || '').trim();
@ -132,19 +133,7 @@ export function buildIdentityLines({ login, firstName, lastName }) {
return lines;
}
export function buildAvatarInitials({ login, firstName, lastName }) {
const first = String(firstName || '').trim();
const last = String(lastName || '').trim();
if (first || last) {
const a = (first[0] || '').toUpperCase();
const b = (last[0] || '').toUpperCase();
const initials = `${a}${b}`.trim();
if (initials) return initials;
}
const cleanLogin = normalizeLogin(login);
return (cleanLogin[0] || '?').toUpperCase();
}
export const buildAvatarInitials = buildAvatarInitialsFromComponent;
export async function loadCurrentRelations() {
const login = normalizeLogin(state.session.login);
@ -210,6 +199,7 @@ export async function loadUserProfileCard(login) {
gender: String(snapshot?.gender || 'unknown').trim().toLowerCase() || 'unknown',
official: Boolean(toggles.official),
shine: Boolean(toggles.shine),
avatar: snapshot?.avatar?.txId ? { ar: String(snapshot.avatar.txId).trim() } : null,
};
}

View File

@ -641,6 +641,29 @@
font-size: 26px;
}
.avatar-image {
position: relative;
overflow: hidden;
}
.avatar-fallback {
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.avatar-image img,
.profile-avatar img,
.node-dot img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
border-radius: 50%;
display: block;
}
.meta-muted {
color: var(--text-muted);
font-size: 13px;

View File

@ -21,8 +21,11 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
private static final Pattern AR_TX_ID_PATTERN = Pattern.compile("^[A-Za-z0-9_-]{43}$");
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_GetUserConnectionsGraph_Request req = (Net_GetUserConnectionsGraph_Request) baseRequest;
@ -139,11 +142,12 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
su.login AS login,
MAX(CASE WHEN up.param = 'gender' THEN up.value END) AS gender_value,
MAX(CASE WHEN up.param = 'official' THEN up.value END) AS official_value,
MAX(CASE WHEN up.param = 'shine' THEN up.value END) AS shine_value
MAX(CASE WHEN up.param = 'shine' THEN up.value END) AS shine_value,
MAX(CASE WHEN up.param = 'ava' THEN up.value END) AS avatar_value
FROM solana_users su
LEFT JOIN users_params up
ON up.login = su.login COLLATE NOCASE
AND up.param IN ('gender', 'official', 'shine')
AND up.param IN ('gender', 'official', 'shine', 'ava')
WHERE su.login COLLATE NOCASE IN (%s)
GROUP BY su.login
ORDER BY su.login
@ -163,6 +167,7 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
meta.gender = normalizeGender(rs.getString("gender_value"));
meta.official = parseToggle(rs.getString("official_value"));
meta.shine = parseToggle(rs.getString("shine_value"));
meta.avatarAr = extractArAvatarTxId(rs.getString("avatar_value"));
out.put(normKey(login), meta);
}
}
@ -188,6 +193,22 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
return "не указан";
}
private String extractArAvatarTxId(String rawValue) {
String value = String.valueOf(rawValue == null ? "" : rawValue).trim();
if (!value.startsWith("AR:")) return null;
String txId = value.substring(3).trim();
if (!AR_TX_ID_PATTERN.matcher(txId).matches()) return null;
return txId;
}
private Net_GetUserConnectionsGraph_Response.AvatarItem makeAvatarItem(String avatarAr) {
String txId = String.valueOf(avatarAr == null ? "" : avatarAr).trim();
if (txId.isEmpty()) return null;
Net_GetUserConnectionsGraph_Response.AvatarItem avatar = new Net_GetUserConnectionsGraph_Response.AvatarItem();
avatar.setAr(txId);
return avatar;
}
private List<Net_GetUserConnectionsGraph_Response.RelativeItem> toRelativeItems(
List<String> logins,
Map<String, UserMeta> metaByLogin
@ -204,6 +225,7 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
it.setLogin(clean);
it.setGender(gender);
it.setGenderLabel(genderLabel(gender));
it.setAvatar(makeAvatarItem(meta == null ? null : meta.avatarAr));
items.add(it);
}
return items;
@ -230,6 +252,7 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
it.setShine(shine);
it.setOfficialLabel(official ? "официальный" : "неофициальный");
it.setShineLabel(shine ? "сияющий" : "несияющий");
it.setAvatar(makeAvatarItem(meta == null ? null : meta.avatarAr));
items.add(it);
}
return items;
@ -239,5 +262,6 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
private String gender = "unknown";
private boolean official = false;
private boolean shine = false;
private String avatarAr = null;
}
}

View File

@ -24,10 +24,18 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
private List<RelativeItem> siblings = new ArrayList<>();
private List<UserMarkItem> allUsers = new ArrayList<>();
public static class AvatarItem {
private String ar;
public String getAr() { return ar; }
public void setAr(String ar) { this.ar = ar; }
}
public static class RelativeItem {
private String login;
private String gender;
private String genderLabel;
private AvatarItem avatar;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
@ -35,6 +43,8 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
public void setGender(String gender) { this.gender = gender; }
public String getGenderLabel() { return genderLabel; }
public void setGenderLabel(String genderLabel) { this.genderLabel = genderLabel; }
public AvatarItem getAvatar() { return avatar; }
public void setAvatar(AvatarItem avatar) { this.avatar = avatar; }
}
public static class UserMarkItem {
@ -43,6 +53,7 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
private boolean shine;
private String officialLabel;
private String shineLabel;
private AvatarItem avatar;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
@ -54,6 +65,8 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
public void setOfficialLabel(String officialLabel) { this.officialLabel = officialLabel; }
public String getShineLabel() { return shineLabel; }
public void setShineLabel(String shineLabel) { this.shineLabel = shineLabel; }
public AvatarItem getAvatar() { return avatar; }
public void setAvatar(AvatarItem avatar) { this.avatar = avatar; }
}
public String getLogin() { return login; }