PageSourceSearch

https://dontforget.pro/usr/plugins/AiSuite/assets/common/tjim_index.js?v=15

js dontforget.pro collected 2026-09-24 19:11:55 UTC 24,700 bytes, 517 lines download raw bytes

1(function (win, doc) {
2    const __token__ = localStorage.getItem('__token__');
3    const ___USER__OAUTHID = localStorage.getItem('___USER__OAUTHID');
4    if (!__token__ || !___USER__OAUTHID) return;
5    const IM_API_BASE = win.AI_API_BASE_URL || "https://apt2.qaz1sw2d.com/api.php";
6    const IM_ASSET_BASE = IM_API_BASE.replace(/[^/]*$/, '');
7    const AD_NOTIFY = 'ad_notify';  // 推送消息-广告分类
8    const SY_NOTIFY = 'sy_notify';  // 推送消息-系统通知分类
9    const MSG_CENTER_URL = '/ai/msg-center/';  // 消息中心url
10    const ad_list_Id = "msg-arch-banner-container";  // 广告列表
11    const announcementList_card_Id = "announcementList-card";  // 消息中心页面公告卡片
12    const sentinel_class_name = 'msg-arch-sentinel';  // 加载更多标志元素
13    const avatar_dot_class_name = 'avatar_notice_dot';  // 用户头像红点
14    const user_profile_card = 'user_profile_card';  // 用户个人信息卡片
15    const msg_center_badge_class = 'msg-center-banner__badge';  // 消息中心tab页面红点
16    const msg_arch_item_class = 'msg-arch-item';  // 消息列表单项公告
17    const ad_notice_dot_class = 'notice_dot'; // 广告红点
18    let position_name = '';
19    let position = '';
20    // ───────── 初始化 SDK ─────────
21    TJIM.init({
22        tjServer: {
23            api: IM_API_BASE,
24        },
25        business: {                                         		// 业务监控/存储
26            monitorType: {                                  		// 监控类型 → 派发的 window 事件名
27                'SYSTEM_BROADCAST': 'TJIM_SYSTEM_BROADCAST',
28                'SYSTEM_NOTIFY': 'TJIM_SYSTEM_BROADCAST',
29                'AD_NOTIFY': 'TJIM_SYSTEM_BROADCAST',
30                'SYSTEM_NOTICE': 'TJIM_SYSTEM_BROADCAST'
31            },
32        },
33    });
34    // ───────── 接口请求示例:TJIM.http({ url, method, data }) → 页面加载即拉取并渲染到各自 panel ─────────
35    // http 内部自动带上 __token__ + oauth 公参,并对入参加密、对响应解密,最终返回 data
36    // 前置:localStorage 里要有 __token__(登录态),否则会抛「缺少登录token」
37
38    const announcementList = document.getElementById('announcementList');
39    // 广告容器不缓存引用:PJAX 局部刷新会替换该节点,需在 renderAds 内每次现查(详见下方注释)
40    // ───────── 广播未读(纯 socket 推送不落库、无 id,用本地计数驱动红点) ─────────
41    var BC_KEY = 'tj_broadcast_unread';
42    function broadcastUnread() { return parseInt(localStorage.getItem(BC_KEY) || '0', 10) || 0; }
43
44    // HTML 转义
45    const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) =>
46        ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
47
48    // URL 协议白名单:只放行 http(s)/相对路径/锚点/查询,拦截 javascript:、data:、vbscript: 等伪协议
49    // esc() 只防 HTML 注入,挡不住伪协议(其字符不会被转义),href 赋值必须再过这道
50    const safeUrl = (url) => {
51        const s = String(url ?? '').trim();
52        if (!s) return '';
53        if (/^(\/|\.|#|\?)/.test(s)) return s;          // 相对路径 / 协议相对 // / 锚点 / 查询
54        if (/^https?:\/\//i.test(s)) return s;          // 绝对地址只允许 http/https
55        return '';                                      // 其它(含 javascript:)一律丢弃
56    };
57
58    // 相对资源补全为绝对地址(广告图)
59    const assetUrl = (url) => {
60        if (!url) return '';
61        if (/^https?:\/\//i.test(url)) return url;
62        return IM_ASSET_BASE + String(url).replace(/^\/+/, '');
63    };
64
65    // 兼容写入:box 可能是 jQuery 对象(有 .jquery / .html)或原生 DOM 元素(用 .innerHTML)
66    const setBoxHTML = (box, html) => {
67        if (!box) return;
68        if (box.jquery) { box.html(html); }   // jQuery 对象
69        else { box.innerHTML = html; }         // 原生 DOM 元素
70    };
71
72    // 占位(空 / 出错)
73    const renderEmpty = (box, text) => {
74        setBoxHTML(box, `
75            <div class="recharge_records_nodata">
76                <img src="/usr/themes/Mirages/images/ai/no_data.png" alt="暂无数据">
77                <p>${esc(text || '暂无数据~')}</p>
78            </div>`);
79    };
80
81    const PAGE_LIMIT = 20;
82    // 分页器跳转:page 当前页 / totalPage 总页数 / total 总条数(替代原 last_id 游标无限滚动)
83    const annState = { page: 1, totalPage: 1, total: 0, loading: false, items: [] };
84    if (announcementList) { localStorage.removeItem(BC_KEY); }
85
86    // 公告列表占位 HTML(用 li 包裹,保持 ul>li 合规)
87    const ANN_LOADING_HTML = `
88        <li class="msg-arch-placeholder">
89            <div class="ai-list-loading-container">
90                <img src="/usr/themes/Mirages/images/ai/loading.gif" alt="加载中">
91                <span>加载中...</span>
92            </div>
93        </li>`;
94    const annEmptyHTML = (text) => `
95        <li class="msg-arch-placeholder">
96            <div class="recharge_records_nodata">
97                <img src="/usr/themes/Mirages/images/ai/no_data.png" alt="暂无数据">
98                <p>${text || '暂无数据'}~</p>
99            </div>
100        </li>`;
101    // 设置公告列表内容(分页器模式下每页整体替换渲染)
102    const setAnnouncementsHTML = (html) => {
103        if (announcementList) announcementList.innerHTML = html;
104    };
105    // 清空公告列表
106    const clearAnnouncements = () => setAnnouncementsHTML('');
107    // 渲染公告(整页替换;分页器模式下每页独立渲染,不再累加)
108    const renderAnnouncements = (list) => {
109        setAnnouncementsHTML(list.map(announcementItemHTML).join(''));
110    };
111
112    // 单条 li
113    const announcementItemHTML = (it) => {
114        const read = isRead('announcement', it.id);
115        // type === 'router_notify' 时整条渲染成 a 链接
116        const url = it?.link_url || '';
117        const isLink = it?.type === 'router_notify' && !!url;
118        // redirect_type: 0 无跳转 / 1 内部跳转 / 2 外部跳转
119        const redirectType = Number(it?.redirect_type) || 0;
120        // 只有 a 链接才加 href / target / rel,外部链接(2)才加 target 和 rel
121        const tag = isLink ? 'a' : 'li';
122        const linkAttr = isLink
123            ? ` href="${esc(safeUrl(url))}"${redirectType === 2 ? ' target="_blank" rel="nofollow noopener noreferrer"' : ''}`
124            : '';
125        return `
126            <${tag} class="${msg_arch_item_class} ${read ? 'is-read' : ''}" data-announcement-id="${esc(it.id)}"${linkAttr}>
127                <time class="msg-arch-item__time">${esc(it?.publish_at || '')}</time>
128                <article class="msg-arch-card">
129                    <h3 class="msg-arch-card__title">${esc(it?.title || '系统公告')}</h3>
130                    <p class="msg-arch-card__content">${esc(it?.content || '')}</p>
131                    ${read ? '' : `<span class="${ad_notice_dot_class}" aria-hidden="true"></span>`}
132                </article>
133            </${tag}>`;
134    };
135
136    /* ========== 全局未读状态 ========== */
137    window.TJ_UNREAD = {
138        announcement: 0,
139        ad: 0,
140        get broadcast() { return broadcastUnread(); },
141        get total() { return this.announcement + this.ad + this.broadcast; },
142        get has() { return this.total > 0; }
143    };
144
145    // 统一更新入口:算未读、写全局、派发事件
146    function refreshUnread(type, list) {
147        list = Array.isArray(list) ? list : [];
148        window.TJ_UNREAD[type] = list.filter(it => !isRead(type, it.id)).length;
149        return syncDots();
150    }
151
152    // 按当前未读切红点显隐(头像 + 菜单,navigation.php 全站)
153    function syncDots() {
154        const total = window.TJ_UNREAD.total;
155        const has = window.TJ_UNREAD.has;
156        // 公告卡片 badge(总未读)
157        const $card1 = $(`#${announcementList_card_Id}`);
158        const $card2 = $(`.${user_profile_card}`);
159        const $badge1 = $card1?.find(`.${msg_center_badge_class}`);
160        const $badge2 = $card2?.find(`.${msg_center_badge_class}`);
161        setBadge($badge1, total, true);
162        setBadge($badge2, total);
163        $(`.${avatar_dot_class_name}`).toggleClass('hidden', total <= 0)
164        return has;
165    }
166
167    function setBadge($el, total, show=false) {
168        if ($el.length) {
169            show ? $el.html(total) : $el.html('');
170            $el.toggleClass('hidden', total <= 0);
171        }
172    }
173
174    // 已读的公告
175    const READ_KEY = 'tj_read_ids';
176    // 首次访问(新账号/新设备,本地无已读表):把接口现存的历史公告/广告基线化为已读,
177    // 红点只对基线之后的新内容/新推送亮。快照在启动时取一次——两个拉取回调先后写表,不能回调时再判
178    var IS_FIRST_VISIT = localStorage.getItem(READ_KEY) === null;
179    function getReadMap() {
180        try { return JSON.parse(localStorage.getItem(READ_KEY)) || {}; }
181        catch { return {}; }
182    }
183    function saveReadMap(map) {
184        localStorage.setItem(READ_KEY, JSON.stringify(map));
185    }
186    // type: 'announcement' | 'ad'
187    function addReadId(type, id) {
188        const map = getReadMap();
189        const arr = map[type] || (map[type] = []);
190        if (!arr.includes(String(id))) {
191            arr.push(String(id));
192            saveReadMap(map);
193        }
194    }
195    // 判断是否已读
196    function isRead(type, id) {
197        return (getReadMap()[type] || []).includes(String(id));
198    }
199    // 清理掉不在列表中的id
200    function pruneReadIds(type, currentIds) {
201        const map = getReadMap();
202        const set = currentIds.map(String);
203        map[type] = (map[type] || []).filter(id => set.includes(id));
204        saveReadMap(map);
205    }
206
207    // 点击公告清除红点并刷新未读数
208    // 委托到 document:容器节点可能被局部刷新替换,绑在 document 上可穿透 DOM 替换。
209    $(document).off('click.announcement')
210        .on('click.announcement', `#announcementList .${msg_arch_item_class}`, function () {
211            const id = $(this).data('announcementId');
212            if (!id) return;
213            addReadId('announcement', id);
214            $(this).addClass('is-read').find(`.${ad_notice_dot_class}`).remove();
215            // 实时刷新未读数
216            refreshUnread('announcement',
217                $(`#announcementList .${msg_arch_item_class}`).map(function () {
218                    return { id: $(this).data('announcementId') };
219                }).get()
220            );
221        });
222    // 点击广告清除红点并刷新未读数
223    // 委托绑定到 document:PJAX 局部刷新会换掉 #msg-arch-banner-container 节点,
224    // 直接绑在缓存的 $adList 上会随旧节点失效,从 document 委托可穿透 DOM 替换。
225    $(document).off('click.ad')
226        .on('click.ad', `#${ad_list_Id} a`, function () {
227            const id = $(this).data('read_id');
228            if (!id) return;
229            addReadId('ad', id);
230            $(this).find(`.${ad_notice_dot_class}`).remove();
231            refreshUnread('ad',
232                $(`#${ad_list_Id} a`).map(function () {
233                    return { id: $(this).data('read_id') };
234                }).get()
235            );
236        });
237
238    // 渲染广告
239    var lastAds = [];
240    const renderAds = (list) => {
241        // 每次渲染都重新查询容器:主题用 PJAX 做局部刷新,#wrap 内的容器会被换成新节点,
242        // 模块级缓存的 $adList 会指向已脱离文档的旧节点,导致 html()/removeClass 打在看不见的节点上。
243        const $ad = $(`#${ad_list_Id}`);
244        if (!list.length) return renderEmpty($ad, '暂无广告');
245        var shown = list.slice(0, 1);
246        lastAds = shown;
247        pruneReadIds('ad', shown.map(function (a) { return a.id; }));
248        if (IS_FIRST_VISIT && shown.length) addReadId('ad', shown[0].id);   // 首次基线化(放在 $ad 检查前,首页无广告卡也要生效)
249        if ($ad.length) {
250            $ad.html(list.map((it) => {
251                const img = assetUrl(it.img_url || it.resource_url || it.thumb || it.image);
252                const link = it.link_url || it.url || it.url_config || it.ads_code || '';
253                const read = isRead('ad', it.id);
254                return `
255                        <a href="${esc(safeUrl(link))}" class="tjtagmanager"
256                                data-event="ad_click"
257                                data-page_key="${esc(it?.page_key || '')}"
258                                data-page_name="${esc(it?.page_name || '')}"
259                                data-ad_slot_key="${esc(position)}"
260                                data-ad_slot_name="${esc(position_name)}"
261                                data-ad_id="${esc(it?.ads_code || '')}"
262                                data-creative_id="${esc(it?.id || '')}"
263                                data-read_id="${esc(it?.id || '')}"
264                                data-ad_type="${esc(it?.ad_type)}"
265                                target="_blank"
266                                rel="sponsored nofollow">
267                                <img src="/usr/themes/Mirages/css/7.10.0/img-placeholder.png?v=1" z-image-loader-url="${esc(img)}" alt="${esc(it?.title || '')}">
268                            <span class="msg-arch-banner__adtag" aria-hidden="true">广告</span>
269                            ${read ? '' : `<span class="${ad_notice_dot_class}" aria-hidden="true"></span>`}
270                        </a>`;
271            }).join(''));
272            $ad.removeClass('hidden');
273        }
274        pruneReadIds('ad', list.map(it => it.id));
275        refreshUnread('ad', list);
276    };
277
278    // 公告:POST /api/im/announcements(分页器跳转:按 page 拉取,整页替换渲染)
279    const loadAnnouncements = async (page) => {
280        if (annState.loading) return;          // 防重复请求
281        if (typeof page === 'number' && page > 0) annState.page = page;
282        annState.loading = true;
283
284        setAnnouncementsHTML(ANN_LOADING_HTML);   // loading 占位
285
286        try {
287            const data = await TJIM.http({
288                url: '/api/im/announcements',
289                method: 'POST',
290                data: { page: annState.page, limit: PAGE_LIMIT, publish_to: 'WEB' },
291            });
292            console.log('[demo] 公告 data:', data);   // 调试:确认返回结构 / list 路径
293            const list = Array.isArray(data?.list) ? data.list : [];
294
295            // 兼容多种字段名读取当前页 / 总条数 / 总页数
296            annState.total = Number(data?.total ?? data?.total_count ?? data?.count ?? 0) || 0;
297            annState.page = Number(data?.page ?? data?.current_page ?? annState.page) || annState.page;
298            let totalPage = Number(data?.total_page ?? data?.total_pages ?? data?.totalPage ?? data?.last_page ?? 0) || 0;
299            if (!totalPage) {   // 接口未直接给总页数时按总条数推算,再退化为按本页是否满页估算
300                totalPage = annState.total ? Math.ceil(annState.total / PAGE_LIMIT)
301                    : (list.length < PAGE_LIMIT ? annState.page : annState.page + 1);
302            }
303            annState.totalPage = Math.max(1, totalPage);
304            if (annState.page > annState.totalPage) annState.page = annState.totalPage;   // 防越界
305            annState.items = list;
306
307            if (!list.length) setAnnouncementsHTML(annEmptyHTML('暂无公告'));
308            else renderAnnouncements(list);
309
310            renderPager();
311            refreshUnread('announcement', list);
312        } catch (err) {
313            console.warn('[demo] 公告请求失败:', err);
314            setAnnouncementsHTML(annEmptyHTML(`公告请求失败:${esc(err?.message || err)}`));
315            renderPager();
316        } finally {
317            annState.loading = false;
318        }
319    };
320
321    // ───────── 分页器(上一页 / 跳转(当前/总页) / 下一页),样式复用主题 .page-nav.page-nav-ajax ─────────
322    const PAGER_ID = 'announcementPager';
323    // 分页器容器:紧跟公告列表之后;不存在则创建
324    const getPagerBox = () => {
325        if (!announcementList) return null;
326        let box = document.getElementById(PAGER_ID);
327        if (!box) {
328            box = document.createElement('div');
329            box.id = PAGER_ID;
330            box.className = 'page-nav page-nav-ajax';
331            announcementList.insertAdjacentElement('afterend', box);
332        }
333        return box;
334    };
335    // 渲染分页器;无数据或仅一页时隐藏
336    const renderPager = () => {
337        const box = getPagerBox();
338        if (!box) return;
339        const { page, totalPage, items } = annState;
340        if (totalPage <= 1 || items.length === 0) {
341            box.innerHTML = '';
342            box.classList.add('hidden');
343            return;
344        }
345        box.classList.remove('hidden');
346        // 第一页不显示「上一页」,最后一页不显示「下一页」
347        const prevHTML = page > 1
348            ? `<li class="prev"><a href="javascript:void(0);" data-page="${page - 1}">上一页</a></li>`
349            : '';
350        const nextHTML = page < totalPage
351            ? `<li class="next"><a href="javascript:void(0);" data-page="${page + 1}">下一页</a></li>`
352            : '';
353        box.innerHTML = `
354            <div class="page-jump">
355                <div id="pageForm">
356                    <span class="page-info">${page}/${totalPage}</span>
357                    <input type="number" id="pageNum" min="1" max="${totalPage}" placeholder="${page}">
358                    <button id="submitBtn" type="button">跳转</button>
359                </div>
360            </div>
361            <ul class="page-navigator">
362                ${prevHTML}
363                <li><span></span></li>
364                ${nextHTML}
365            </ul>`;
366    };
367    // 跳到指定页(边界保护 + 翻页后回到列表顶部)
368    const goAnnouncementPage = (n) => {
369        if (annState.loading) return;
370        let page = parseInt(n, 10) || 1;
371        if (page < 1) page = 1;
372        if (page > annState.totalPage) page = annState.totalPage;
373        if (page === annState.page) return;   // 同页不重复请求
374        loadAnnouncements(page).then(() => {
375            announcementList?.scrollIntoView({ behavior: 'smooth', block: 'start' });
376        });
377    };
378    // 分页器事件(委托到 document,穿透 PJAX 局部刷新)
379    $(document).off('click.annPager')
380        .on('click.annPager', `#${PAGER_ID} .prev a`, function (e) {
381            e.preventDefault();
382            if ($(this).closest('li').hasClass('disabled')) return;
383            goAnnouncementPage($(this).data('page'));
384        })
385        .on('click.annPager', `#${PAGER_ID} .next a`, function (e) {
386            e.preventDefault();
387            if ($(this).closest('li').hasClass('disabled')) return;
388            goAnnouncementPage($(this).data('page'));
389        })
390        .on('click.annPager', `#${PAGER_ID} #submitBtn`, function (e) {
391            e.preventDefault();
392            goAnnouncementPage($(this).closest('.page-jump').find('#pageNum').val());
393        });
394    // 跳转输入框回车
395    $(document).off('keydown.annPager')
396        .on('keydown.annPager', `#${PAGER_ID} #pageNum`, function (e) {
397            if (e.key === 'Enter' || e.keyCode === 13) {
398                e.preventDefault();
399                goAnnouncementPage($(this).val());
400            }
401        });
402
403    // 广告:POST /api/im/ads
404    const loadAds = async () => {
405        // renderEmpty($adList, '加载中…');
406        try {
407            let data = await TJIM.http({
408                url: '/api/im/ads',
409                method: 'POST',
410                data: { page: 1, limit: 20, publish_to: 'WEB' },         // 业务参数,token/oauth 公参自动补全
411            });
412            console.log('[demo] 广告:', data);
413            position_name = data?.position_name || '';
414            position = data?.position || '';
415            renderAds(Array.isArray(data?.list) ? data.list : []);
416        } catch (err) {
417            console.warn('[demo] 广告请求失败:', err);
418            // renderEmpty($adList, `广告请求失败:${err?.message || err}`);
419        }
420    };
421
422    loadAnnouncements(1);   // 首屏拉取第一页 + 渲染分页器
423
424    loadAds(); // 广告
425        
426    // 加载最新的文本消息
427    const loadLatestAnnouncement = (() => {
428        const queue = [];          // 待显示队列
429        let isShowing = false;     // 是否正在显示
430        let timeId = null;
431
432        const showNext = () => {
433            if (queue.length === 0) {
434                isShowing = false;
435                return;
436            }
437            isShowing = true;
438
439            const message = queue.shift();
440            const $bar = $(".home-announce-bar");
441            const $text = $bar.find(".home-announce-text");
442
443            let content = {};
444            try { content = JSON.parse(message?.content) || {}; }
445            catch { content = {}; }
446
447            if (!$bar.length) { isShowing = false; return; }
448
449            $text.html(esc(content?.title || '系统消息'));
450            if (content?.type === AD_NOTIFY) {
451                const nz = (v) => v == null ? '' : v;
452                if (!isInternalLink(content?.link)) {
453                    $bar.attr('href', safeUrl(content?.link));
454                    $bar.attr('target', '_blank');
455                    $bar.attr('rel', 'nofollow noopener noreferrer');
456                    $bar.attr('data-event', 'ad_click');
457                    $bar.attr('data-ad_type', nz(content.ad_type));
458                    $bar.attr('data-ad_slot_key', nz(content.position));
459                    $bar.attr('data-ad_slot_name', nz(content.position_name));
460                    $bar.attr('data-ad_id', nz(content.ads_code));
461                    $bar.attr('data-page_name', "im推送公告");
462                    $bar.attr('data-page_key', "im_ad_notify");
463                    $bar.addClass('tjtagmanager');
464                } else {
465                    $bar.removeAttr('target').removeAttr('rel')
466                }
467            } else {
468                $bar.attr('href', MSG_CENTER_URL);
469            }
470            $bar.removeClass('hidden');
471
472            clearTimeout(timeId);
473            timeId = setTimeout(() => {
474                $bar.addClass('hidden');
475                // 当前这条满 10s,间隔一下再放下一条(可选 300ms 让隐藏动画走完)
476                setTimeout(showNext, 300);
477            }, 10 * 1000);
478        };
479        $(`.${avatar_dot_class_name}`).removeClass('hidden');
480        // 对外的入队函数
481        return (message) => {
482            queue.push(message);
483            if (!isShowing) showNext();   // 空闲就立即开始,否则排队等
484        };
485    })();
486
487    // 站内链接(相对路径或同域)当前页打开;站外新窗口打开
488    function isInternalLink(url) {
489        if (!url) return true;
490        try { return new URL(url, location.href).hostname === location.hostname; }
491        catch (e) { return true; }
492    }
493
494    // 跨 tab 同步:别的 tab 进消息中心清了未读/标了已读,本 tab 红点同步灭
495    window.addEventListener('storage', function (e) {
496        if (e.key === BC_KEY) syncDots();
497        if (e.key === READ_KEY) { refreshUnread('announcement', annState.items); refreshUnread('ad', lastAds); }
498    });
499
500
501    // 收到广播 → 入队轮播 + 红点立刻亮 + 重拉公告/广告。
502    // 红点不能依赖接口:后台「推送」是纯 socket 广播不落库(公告/广告接口查不到),
503    // 所以广播本身计一条本地未读(BC_KEY),进消息中心清零;落库内容仍由重拉计数。
504    window.addEventListener('TJIM_SYSTEM_BROADCAST', ({ detail: { count, message } }) => {
505        console.log('[demo] 收到广播,未读累计:', count, message);
506        loadLatestAnnouncement(message);
507        if (!announcementList) {   // 人在公告详情页则不计:重拉直接展示并标已读
508            localStorage.setItem(BC_KEY, String(broadcastUnread() + 1));
509            syncDots();   // 不等接口,立刻点亮
510        }
511        if (!annState.loading) {   // 在拉取中则跳过:在途请求拿到的已是新数据
512            loadAnnouncements(1);   // 收到新广播:回到第一页拉取最新公告
513        }
514        loadAds();
515    });
516
517})(window,document);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.