Charater rating badge on private chats

This commit is contained in:
Mr. Stallion 2019-07-15 11:59:16 -05:00
parent dc87871ad0
commit 6183acc30a
18 changed files with 475 additions and 79 deletions

View File

@ -1,7 +1,7 @@
<template>
<modal :buttons="false" ref="dialog" @open="onOpen" @close="onClose" style="width:98%" dialogClass="ads-dialog">
<template slot="title">
Channel Ads for {{character.name}}
Channel Ads for <user :character="character">{{character.name}}</user>
</template>
<div class="row ad-viewer" ref="pageBody">
@ -25,9 +25,10 @@ import { Character } from '../fchat/interfaces';
import { AdCachedPosting } from '../learn/ad-cache';
import core from './core';
import {formatTime} from './common';
import UserView from './UserView.vue';
@Component({
components: {modal: Modal}
components: {modal: Modal, user: UserView}
})
export default class AdView extends CustomDialog {
@Prop({required: true})

View File

@ -36,7 +36,7 @@
import core from './core';
import {Character, Connection} from './interfaces';
import l from './localize';
import UserView from './user_view';
import UserView from './UserView.vue';
type Options = {
kinks: Kink[],

View File

@ -25,7 +25,11 @@
{{conversations.consoleTab.name}}
</a>
</div>
{{l('chat.pms')}}
<div @click.prevent="showAddPmPartner()" class="pm-add"><a href="#"><span class="fas fa-plus"></span></a></div>
<div class="list-group conversation-nav" ref="privateConversations">
<a v-for="conversation in conversations.privateConversations" href="#" @click.prevent="conversation.show()"
:class="getClasses(conversation)" :data-character="conversation.character.name" data-touch="false"
@ -91,6 +95,7 @@
<user-menu ref="userMenu" :reportDialog="$refs['reportDialog']"></user-menu>
<recent-conversations ref="recentDialog"></recent-conversations>
<image-preview ref="imagePreview"></image-preview>
<add-pm-partner ref="addPmPartnerDialog"></add-pm-partner>
</div>
</template>
@ -107,12 +112,13 @@
import core from './core';
import {Character, Connection, Conversation} from './interfaces';
import l from './localize';
import PmPartnerAdder from './PmPartnerAdder.vue';
import RecentConversations from './RecentConversations.vue';
import ReportDialog from './ReportDialog.vue';
import SettingsView from './SettingsView.vue';
import Sidebar from './Sidebar.vue';
import StatusSwitcher from './StatusSwitcher.vue';
import {getStatusIcon} from './user_view';
import {getStatusIcon} from './UserView.vue';
import UserList from './UserList.vue';
import UserMenu from './UserMenu.vue';
import ImagePreview from './ImagePreview.vue';
@ -128,7 +134,8 @@
'user-list': UserList, channels: ChannelList, 'status-switcher': StatusSwitcher, 'character-search': CharacterSearch,
settings: SettingsView, conversation: ConversationView, 'report-dialog': ReportDialog, sidebar: Sidebar,
'user-menu': UserMenu, 'recent-conversations': RecentConversations,
'image-preview': ImagePreview
'image-preview': ImagePreview,
'add-pm-partner': PmPartnerAdder
}
})
export default class ChatView extends Vue {
@ -294,6 +301,10 @@
(<StatusSwitcher>this.$refs['statusDialog']).show();
}
showAddPmPartner(): void {
(<PmPartnerAdder>this.$refs['addPmPartnerDialog']).show();
}
userMenuHandle(e: MouseEvent | TouchEvent): void {
(<UserMenu>this.$refs['userMenu']).handleEvent(e);
}
@ -329,6 +340,12 @@
user-select: text;
}
.pm-add {
font-size: 90%;
float: right;
margin-right: 5px;
}
.list-group.conversation-nav {
margin-bottom: 10px;
.list-group-item {

View File

@ -4,7 +4,7 @@
<img :src="characterImage" style="height:60px;width:60px;margin-right:10px" v-if="settings.showAvatars"/>
<div style="flex:1;position:relative;display:flex;flex-direction:column">
<div>
<user :character="conversation.character"></user>
<user :character="conversation.character" :match="true"></user>
<a href="#" @click.prevent="showLogs()" class="btn">
<span class="fa fa-file-alt"></span> <span class="btn-text">{{l('logs.title')}}</span>
</a>
@ -17,6 +17,10 @@
<a href="#" @click.prevent="showAds()" class="btn">
<span class="fa fa-ad"></span><span class="btn-text">Ads</span>
</a>
<a href="#" @click.prevent="showChannels()" class="btn">
<span class="fa fa-tv"></span><span class="btn-text">Channels</span>
</a>
</div>
<div style="overflow:auto;max-height:50px">
{{l('status.' + conversation.character.status)}}
@ -140,7 +144,8 @@
<settings ref="settingsDialog" :conversation="conversation"></settings>
<logs ref="logsDialog" :conversation="conversation"></logs>
<manage-channel ref="manageDialog" v-if="isChannel(conversation)" :channel="conversation.channel"></manage-channel>
<ad-view ref="adViewer" v-if="isPrivate(conversation)" :character="conversation.character"></ad-view>
<ad-view ref="adViewer" v-if="isPrivate(conversation) && conversation.character" :character="conversation.character"></ad-view>
<channel-list ref="channelList" v-if="isPrivate(conversation)" :character="conversation.character"></channel-list>
</div>
</template>
@ -163,13 +168,14 @@
import MessageView from './message_view';
import ReportDialog from './ReportDialog.vue';
import {isCommand} from './slash_commands';
import UserView from './user_view';
import UserView from './UserView.vue';
import UserChannelList from './UserChannelList.vue';
@Component({
components: {
user: UserView, 'bbcode-editor': Editor, 'manage-channel': ManageChannel, settings: ConversationSettings,
logs: Logs, 'message-view': MessageView, bbcode: BBCodeView, 'command-help': CommandHelp,
'ad-view': AdView
'ad-view': AdView, 'channel-list': UserChannelList
}
})
export default class ConversationView extends Vue {
@ -411,6 +417,10 @@
(<AdView>this.$refs['adViewer']).show();
}
showChannels(): void {
(<UserChannelList>this.$refs['channelList']).show();
}
isAutopostingAds(): boolean {
return this.conversation.adManager.isActive();
@ -635,6 +645,43 @@
}
}
.user-view {
.match-found {
margin-left: 3px;
padding-left: 2px;
padding-right: 2px;
border-radius: 3px;
color: rgba(255, 255, 255, 0.8);
font-size: 75%;
padding-top: 0;
padding-bottom: 0;
text-align: center;
display: inline-block;
text-transform: uppercase;
&.match {
background-color: rgb(0, 142, 0);
border: solid 1px rgb(0, 113, 0);
}
&.weak-match {
background-color: rgb(0, 80, 0);
border: 1px solid rgb(0, 64, 0);
}
&.weak-mismatch {
background-color: rgb(152, 134, 0);
border: 1px solid rgb(142, 126, 0);
}
&.mismatch {
background-color: rgb(171, 0, 0);
border: 1px solid rgb(128, 0, 0);
}
}
}
.message {
&.message-event {
font-size: 85%;

40
chat/PmPartnerAdder.vue Normal file
View File

@ -0,0 +1,40 @@
<template>
<modal action="Open Conversation" ref="dialog" @submit="submit" style="width:98%" dialogClass="ads-dialog" buttonText="Open">
<div>
<input type="text" id="name" v-model="name" placeholder="Name" />
<div class="error" v-if="error">{{error}}</div>
</div>
</modal>
</template>
<script lang="ts">
import { Component } from '@f-list/vue-ts';
import CustomDialog from '../components/custom_dialog';
import Modal from '../components/Modal.vue';
import core from './core';
@Component({
components: {modal: Modal}
})
export default class PmPartnerAdder extends CustomDialog {
name = '';
error: string | null = null;
submit(): void {
const c = core.characters.get(this.name);
if (c) {
const conversation = core.conversations.getPrivate(c);
conversation.show();
this.name = '';
this.error = '';
} else {
this.error = `Unknown character '${this.name}'`;
}
}
}
</script>

View File

@ -24,7 +24,7 @@
import core from './core';
import {Character, Conversation} from './interfaces';
import l from './localize';
import UserView from './user_view';
import UserView from './UserView.vue';
@Component({
components: {'user-view': UserView, 'channel-view': ChannelView, modal: Modal, tabs: Tabs}

View File

@ -30,7 +30,7 @@
import core from './core';
import {Character, userStatuses} from './interfaces';
import l from './localize';
import {getStatusIcon} from './user_view';
import {getStatusIcon} from './UserView.vue';
@Component({
components: {modal: Modal, editor: Editor, dropdown: Dropdown}

76
chat/UserChannelList.vue Normal file
View File

@ -0,0 +1,76 @@
<template>
<modal :buttons="false" ref="dialog" style="width:98%" dialogClass="">
<template slot="title">
Channels for <user :character="character">{{character.name}}</user>
</template>
<div class="user-channel-list" ref="pageBody">
<template v-for="channel in channels">
<h3><a href="#" @click.prevent="jumpToChannel(channel)">#{{channel.name}}</a></h3>
</template>
</div>
</modal>
</template>
<script lang="ts">
import * as _ from 'lodash';
import { Component, Hook, Prop, Watch } from '@f-list/vue-ts';
import CustomDialog from '../components/custom_dialog';
import Modal from '../components/Modal.vue';
import { Character } from '../fchat/interfaces';
import core from './core';
import { Conversation } from './interfaces';
import UserView from './UserView.vue';
import ChannelConversation = Conversation.ChannelConversation;
@Component({
components: {modal: Modal, user: UserView}
})
export default class UserChannelList extends CustomDialog {
@Prop({required: true})
readonly character!: Character;
channels: ChannelConversation[] = [];
@Watch('character')
onNameUpdate(): void {
this.update();
}
@Hook('mounted')
onMounted(): void {
this.update();
}
update(): void {
if (!this.character) {
this.channels = [];
return;
}
this.channels = _.sortBy(
_.filter(
core.conversations.channelConversations,
(cc: ChannelConversation) => !!cc.channel.members[this.character.name]
),
'name'
);
}
jumpToChannel(channel: ChannelConversation): void {
channel.show();
}
}
</script>
<style lang="scss">
.user-channel-list h3 {
font-size: 120%;
}
</style>

View File

@ -36,7 +36,7 @@
import {Channel, Character, Conversation} from './interfaces';
import l from './localize';
import Sidebar from './Sidebar.vue';
import UserView from './user_view';
import UserView from './UserView.vue';
@Component({
components: {user: UserView, sidebar: Sidebar, tabs: Tabs}

View File

@ -21,6 +21,9 @@
<span class="far fa-fw fa-sticky-note"></span>{{l('user.memo')}}</a>
<a tabindex="-1" href="#" @click.prevent="setBookmarked()" class="list-group-item list-group-item-action">
<span class="far fa-fw fa-bookmark"></span>{{l('user.' + (character.isBookmarked ? 'unbookmark' : 'bookmark'))}}</a>
<a tabindex="-1" href="#" @click.prevent="showAdLogs()" class="list-group-item list-group-item-action" :class="{ disabled: !hasAdLogs()}">
<span class="far fa-fw fa-ad"></span>Show ad log
</a>
<a tabindex="-1" href="#" @click.prevent="setHidden()" class="list-group-item list-group-item-action" v-show="!isChatOp">
<span class="fa fa-fw fa-eye-slash"></span>{{l('user.' + (isHidden ? 'unhide' : 'hide'))}}</a>
<a tabindex="-1" href="#" @click.prevent="report()" class="list-group-item list-group-item-action" style="border-top-width:1px">
@ -36,6 +39,7 @@
<div style="float:right;text-align:right;">{{getByteLength(memo)}} / 1000</div>
<textarea class="form-control" v-model="memo" :disabled="memoLoading" maxlength="1000"></textarea>
</modal>
<ad-view ref="adViewDialog" :character="character" v-if="character"></ad-view>
</div>
</template>
@ -43,6 +47,7 @@
import {Component, Prop} from '@f-list/vue-ts';
import Vue from 'vue';
import Modal from '../components/Modal.vue';
import AdView from './AdView.vue';
import {BBCodeView} from './bbcode';
import {characterImage, errorToString, getByteLength, profileLink} from './common';
import core from './core';
@ -51,7 +56,7 @@
import ReportDialog from './ReportDialog.vue';
@Component({
components: {bbcode: BBCodeView, modal: Modal}
components: {bbcode: BBCodeView, modal: Modal, 'ad-view': AdView}
})
export default class UserMenu extends Vue {
@Prop({required: true})
@ -120,6 +125,30 @@
.catch((e: object) => alert(errorToString(e)));
}
showAdLogs(): void {
if (!this.hasAdLogs()) {
return;
}
(<AdView>this.$refs['adViewDialog']).show();
}
hasAdLogs(): boolean {
if (!this.character) {
return false;
}
const cache = core.cache.adCache.get(this.character.name);
if (!cache) {
return false;
}
return (cache.count() > 0);
}
get isChannelMod(): boolean {
if(this.channel === undefined) return false;
if(core.characters.ownCharacter.isChatOp) return true;

219
chat/UserView.vue Normal file
View File

@ -0,0 +1,219 @@
<!-- Linebreaks inside this template will break BBCode views -->
<template><span :class="userClass" v-bind:bbcodeTag.prop="'user'" v-bind:character.prop="character" v-bind:channel.prop="channel"><span v-if="!!statusClass" :class="statusClass"></span><span v-if="!!rankIcon" :class="rankIcon"></span>{{character.name}}<span v-if="!!matchClass" :class="matchClass">{{getMatchScoreTitle(matchScore)}}</span></span></template>
<script lang="ts">
import { Component, Hook, Prop } from '@f-list/vue-ts';
import Vue from 'vue';
import {Channel, Character} from '../fchat';
import { Score, Scoring } from '../learn/matcher';
import core from './core';
import { EventBus } from './event-bus';
export function getStatusIcon(status: Character.Status): string {
switch(status) {
case 'online':
return 'far fa-user';
case 'looking':
return 'fa fa-eye';
case 'dnd':
return 'fa fa-minus-circle';
case 'offline':
return 'fa fa-ban';
case 'away':
return 'far fa-circle';
case 'busy':
return 'fa fa-cog';
case 'idle':
return 'far fa-clock';
case 'crown':
return 'fa fa-birthday-cake';
}
}
@Component({
components: {
}
})
export default class UserView extends Vue {
@Prop({required: true})
readonly character!: Character;
@Prop()
readonly channel?: Channel;
@Prop()
readonly showStatus?: boolean = true;
@Prop()
readonly bookmark?: boolean = false;
@Prop()
readonly match?: boolean = false;
userClass = '';
rankIcon: string | null = null;
statusClass: string | null = null;
matchClass: string | null = null;
matchScore: number | null = null;
// tslint:disable-next-line no-any
scoreWatcher: ((event: any) => void) | null = null;
@Hook('mounted')
onMounted(): void {
this.update();
if ((this.match) && (!this.matchClass)) {
if (this.scoreWatcher) {
EventBus.$off('character-score', this.scoreWatcher);
}
// tslint:disable-next-line no-unsafe-any no-any
this.scoreWatcher = (event: any): void => {
// tslint:disable-next-line no-unsafe-any no-any
if ((event.character) && (event.character.name === this.character.name)) {
this.update();
if (this.scoreWatcher) {
EventBus.$off('character-score', this.scoreWatcher);
delete this.scoreWatcher;
}
}
};
EventBus.$on(
'character-score',
this.scoreWatcher
);
}
}
@Hook('beforeDestroy')
onBeforeDestroy(): void {
if (this.scoreWatcher)
EventBus.$off('character-score', this.scoreWatcher);
}
@Hook('beforeUpdate')
onBeforeUpdate(): void {
this.update();
}
update(): void {
this.rankIcon = null;
this.statusClass = null;
this.matchClass = null;
if (this.match) console.log('Update');
if(this.character.isChatOp) {
this.rankIcon = 'far fa-gem';
} else if(this.channel !== undefined) {
this.rankIcon = (this.channel.owner === this.character.name)
? 'fa fa-key'
: this.channel.opList.indexOf(this.character.name) !== -1
? (this.channel.id.substr(0, 4) === 'adh-' ? 'fa fa-shield-alt' : 'fa fa-star')
: null;
}
if ((this.showStatus) || (this.character.status === 'crown'))
this.statusClass = `fa-fw ${getStatusIcon(this.character.status)}`;
if (this.match) console.log('Update prematch');
if (this.match) {
const cache = core.cache.profileCache.getSync(this.character.name);
if (cache) {
this.matchClass = `match-found ${Score.getClasses(cache.matchScore)}`;
this.matchScore = cache.matchScore;
} else {
core.cache.addProfile(this.character.name);
}
}
if (this.match) console.log('Update post match');
const gender = this.character.gender !== undefined ? this.character.gender.toLowerCase() : 'none';
const isBookmark = (this.bookmark) && (core.connection.isOpen) && (core.state.settings.colorBookmarks) &&
((this.character.isFriend) || (this.character.isBookmarked));
this.userClass = `user-view gender-${gender}${isBookmark ? ' user-bookmark' : ''}`;
if (this.match) console.log('Update done');
}
getMatchScoreTitle(score: number | null): string {
switch (score) {
case Scoring.MATCH:
return 'Great';
case Scoring.WEAK_MATCH:
return 'Good';
case Scoring.WEAK_MISMATCH:
return 'Maybe';
case Scoring.MISMATCH:
return 'No';
}
return '';
}
}
//tslint:disable-next-line:variable-name
/* const UserView = Vue.extend({
functional: true,
render(this: void | Vue, createElement: CreateElement, context?: RenderContext): VNode {
const props = <{character: Character, channel?: Channel, showStatus?: true, bookmark?: false, match?: false}>(
context !== undefined ? context.props : (<Vue>this).$options.propsData);
const character = props.character;
let matchClasses: string | undefined;
if (props.match) {
const cache = core.cache.profileCache.getSync(character.name);
if (cache) {
matchClasses = Score.getClasses(cache.matchScore);
}
}
let rankIcon;
if(character.isChatOp) rankIcon = 'far fa-gem';
else if(props.channel !== undefined)
rankIcon = props.channel.owner === character.name ? 'fa fa-key' : props.channel.opList.indexOf(character.name) !== -1 ?
(props.channel.id.substr(0, 4) === 'adh-' ? 'fa fa-shield-alt' : 'fa fa-star') : '';
else rankIcon = '';
const children: (VNode | string)[] = [character.name];
if(rankIcon !== '') children.unshift(createElement('span', {staticClass: rankIcon}));
if(props.showStatus !== undefined || character.status === 'crown')
children.unshift(createElement('span', {staticClass: `fa-fw ${getStatusIcon(character.status)}`}));
const gender = character.gender !== undefined ? character.gender.toLowerCase() : 'none';
const isBookmark = props.bookmark !== false && core.connection.isOpen && core.state.settings.colorBookmarks &&
(character.isFriend || character.isBookmarked);
return createElement('span', {
attrs: {class: `user-view gender-${gender}${isBookmark ? ' user-bookmark' : ''} ${matchClasses}`},
domProps: {character, channel: props.channel, bbcodeTag: 'user'}
}, children);
}
});
export default UserView;
*/
</script>

View File

@ -8,7 +8,7 @@ import {characterImage} from './common';
import core from './core';
import {Character} from './interfaces';
import {default as UrlView} from '../bbcode/UrlTagView.vue';
import UserView from './user_view';
import UserView from './UserView.vue';
export const BBCodeView: Component = {
functional: true,

View File

@ -9,6 +9,7 @@ import ChannelConversation = Conversation.ChannelConversation;
* 'imagepreview-show': {url: string}
* 'imagepreview-toggle-stickyness': {url: string}
* 'character-data': {character: Character}
* 'character-score': {character: Character, score: number}
* 'private-message': {message: Message}
* 'channel-ad': {message: Message, channel: Conversation, profile: ComplexCharacter | undefined}
* 'channel-message': {message: Message, channel: Conversation}

View File

@ -6,7 +6,7 @@ import {BBCodeView} from './bbcode';
import {formatTime} from './common';
import core from './core';
import {Conversation} from './interfaces';
import UserView from './user_view';
import UserView from './UserView.vue';
const userPostfix: {[key: number]: string | undefined} = {
[Conversation.Message.Type.Message]: ': ',

View File

@ -1,58 +0,0 @@
// TODO convert this to single-file once Vue supports it for functional components.
//template:
//<span class="gender" :class="genderClass" @click="click" @contextmenu.prevent="showMenu" style="cursor:pointer;" ref="main"><span
//class="fa" :class="statusIcon"></span> <span class="fa" :class="rankIcon"></span>{{character.name}}</span>
import Vue, {CreateElement, RenderContext, VNode} from 'vue';
import {Channel, Character} from '../fchat';
import core from './core';
export function getStatusIcon(status: Character.Status): string {
switch(status) {
case 'online':
return 'far fa-user';
case 'looking':
return 'fa fa-eye';
case 'dnd':
return 'fa fa-minus-circle';
case 'offline':
return 'fa fa-ban';
case 'away':
return 'far fa-circle';
case 'busy':
return 'fa fa-cog';
case 'idle':
return 'far fa-clock';
case 'crown':
return 'fa fa-birthday-cake';
}
}
//tslint:disable-next-line:variable-name
const UserView = Vue.extend({
functional: true,
render(this: void | Vue, createElement: CreateElement, context?: RenderContext): VNode {
const props = <{character: Character, channel?: Channel, showStatus?: true, bookmark?: false}>(
context !== undefined ? context.props : (<Vue>this).$options.propsData);
const character = props.character;
let rankIcon;
if(character.isChatOp) rankIcon = 'far fa-gem';
else if(props.channel !== undefined)
rankIcon = props.channel.owner === character.name ? 'fa fa-key' : props.channel.opList.indexOf(character.name) !== -1 ?
(props.channel.id.substr(0, 4) === 'adh-' ? 'fa fa-shield-alt' : 'fa fa-star') : '';
else rankIcon = '';
const children: (VNode | string)[] = [character.name];
if(rankIcon !== '') children.unshift(createElement('span', {staticClass: rankIcon}));
if(props.showStatus !== undefined || character.status === 'crown')
children.unshift(createElement('span', {staticClass: `fa-fw ${getStatusIcon(character.status)}`}));
const gender = character.gender !== undefined ? character.gender.toLowerCase() : 'none';
const isBookmark = props.bookmark !== false && core.connection.isOpen && core.state.settings.colorBookmarks &&
(character.isFriend || character.isBookmarked);
return createElement('span', {
attrs: {class: `user-view gender-${gender}${isBookmark ? ' user-bookmark' : ''}`},
domProps: {character, channel: props.channel, bbcodeTag: 'user'}
}, children);
}
});
export default UserView;

View File

@ -3,7 +3,7 @@ import core from '../chat/core';
import { ChannelAdEvent, ChannelMessageEvent, CharacterDataEvent, EventBus } from '../chat/event-bus';
import { Conversation } from '../chat/interfaces';
import { methods } from '../site/character_page/data_store';
import { Character } from '../site/character_page/interfaces';
import { Character as ComplexCharacter } from '../site/character_page/interfaces';
import { Gender } from './matcher';
import { AdCache } from './ad-cache';
import { ChannelConversationCache } from './channel-conversation-cache';
@ -74,7 +74,15 @@ export class CacheManager {
}
updateAdScoringForProfile(c: Character, score: number): void {
updateAdScoringForProfile(c: ComplexCharacter, score: number): void {
EventBus.$emit(
'character-score',
{
character: c,
score
}
);
_.each(
core.conversations.channelConversations,
(ch: ChannelConversation) => {
@ -92,7 +100,7 @@ export class CacheManager {
}
async addProfile(character: string | Character): Promise<void> {
async addProfile(character: string | ComplexCharacter): Promise<void> {
if (typeof character === 'string') {
// console.log('Learn discover', character);
@ -237,7 +245,7 @@ export class CacheManager {
}
setProfile(c: Character): void {
setProfile(c: ComplexCharacter): void {
this.characterProfiler = new CharacterProfiler(c, this.adCache);
}
}

View File

@ -30,6 +30,17 @@ export class ProfileCache extends AsyncCache<CharacterCacheRecord> {
}
getSync(name: string): CharacterCacheRecord | null {
const key = AsyncCache.nameKey(name);
if (key in this.cache) {
return this.cache[key];
}
return null;
}
async get(name: string, skipStore: boolean = false): Promise<CharacterCacheRecord | null> {
const key = AsyncCache.nameKey(name);

View File

@ -6,8 +6,8 @@ This repository contains a modified version of the mainline F-Chat 3.0 client.
## Key Differences
* Ads view
* Highlight ads from characters most interesting to you / hide ads from characters clearly incompatible
* View recent ads from a character on any channel to which you subscribe
* Highlight ads from characters most interesting to you
* View a character's recent ads
* Ad auto-posting
* Manage channel's ad settings via "Tab Settings"
* Automatically re-post ads every 11-18 minutes (randomized) for up to 180 minutes
@ -35,6 +35,11 @@ This repository contains a modified version of the mainline F-Chat 3.0 client.
* Improvements to log browsing
* Fix broken BBCode, such as `[big]` in character profiles
* Which channels my chart partner is on?
* Reposition ad settings and toggle
* Cache image list, guestbook pages
* Bug: Invalid Ticket
* Bug: Posting on the same second
* Bug: Images tab count is off
# F-List Exported