diff --git a/res/font/ffz-fontello.eot b/res/font/ffz-fontello.eot
index fe672350..d7f8d3a1 100644
Binary files a/res/font/ffz-fontello.eot and b/res/font/ffz-fontello.eot differ
diff --git a/res/font/ffz-fontello.svg b/res/font/ffz-fontello.svg
index 7a1d4eb1..61b5077b 100644
--- a/res/font/ffz-fontello.svg
+++ b/res/font/ffz-fontello.svg
@@ -114,6 +114,10 @@
+
+
+
+
@@ -128,6 +132,10 @@
+
+
+
+
diff --git a/res/font/ffz-fontello.ttf b/res/font/ffz-fontello.ttf
index 73be6fc1..41678442 100644
Binary files a/res/font/ffz-fontello.ttf and b/res/font/ffz-fontello.ttf differ
diff --git a/res/font/ffz-fontello.woff b/res/font/ffz-fontello.woff
index c32dc3ec..c2849308 100644
Binary files a/res/font/ffz-fontello.woff and b/res/font/ffz-fontello.woff differ
diff --git a/res/font/ffz-fontello.woff2 b/res/font/ffz-fontello.woff2
index e211fd39..8481f2dd 100644
Binary files a/res/font/ffz-fontello.woff2 and b/res/font/ffz-fontello.woff2 differ
diff --git a/src/main.js b/src/main.js
index 66a800cf..3114d80c 100644
--- a/src/main.js
+++ b/src/main.js
@@ -151,7 +151,7 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}`
FrankerFaceZ.Logger = Logger;
const VER = FrankerFaceZ.version_info = {
- major: 4, minor: 4, revision: 1,
+ major: 4, minor: 4, revision: 2,
commit: __git_commit__,
build: __webpack_hash__,
toString: () =>
diff --git a/src/sites/twitch-twilight/modules/directory/browse_popular.js b/src/sites/twitch-twilight/modules/directory/browse_popular.js
index df7b8d73..b458de14 100644
--- a/src/sites/twitch-twilight/modules/directory/browse_popular.js
+++ b/src/sites/twitch-twilight/modules/directory/browse_popular.js
@@ -23,10 +23,10 @@ export default class BrowsePopular extends SiteModule {
onEnable() {
// Popular Directory Channel Cards
- this.apollo.ensureQuery(
+ /*this.apollo.ensureQuery(
'BrowsePage_Popular',
'data.streams.edges.0.node.createdAt'
- );
+ );*/
}
modifyStreams(res) { // eslint-disable-line class-methods-use-this
diff --git a/src/sites/twitch-twilight/modules/directory/following.jsx b/src/sites/twitch-twilight/modules/directory/following.jsx
index cf44d58c..b58100aa 100644
--- a/src/sites/twitch-twilight/modules/directory/following.jsx
+++ b/src/sites/twitch-twilight/modules/directory/following.jsx
@@ -169,7 +169,7 @@ export default class Following extends SiteModule {
}
ensureQueries () {
- this.apollo.ensureQuery(
+ /*this.apollo.ensureQuery(
'FollowedChannels_RENAME2',
'data.currentUser.followedLiveUsers.nodes.0.stream.createdAt'
);
@@ -182,7 +182,7 @@ export default class Following extends SiteModule {
this.apollo.ensureQuery(
'RecommendedChannels',
'data.currentUser.recommendations.liveRecommendations.nodes.0.createdAt'
- );
+ );*/
if ( this.router.current_name !== 'dir-following' )
return;
@@ -197,11 +197,11 @@ export default class Following extends SiteModule {
get('data.currentUser.followedHosts.nodes.0.hosting.stream.createdAt', n) !== undefined
);
- else if ( bit === 'live' )
+ /*else if ( bit === 'live' )
this.apollo.ensureQuery(
'FollowingLive_CurrentUser',
'data.currentUser.followedLiveUsers.nodes.0.stream.createdAt'
- );
+ );*/
else if ( bit === 'hosts' )
this.apollo.ensureQuery(
@@ -217,7 +217,7 @@ export default class Following extends SiteModule {
}
destroyHostMenu(event) {
- if (!event || event && event.target && event.target.closest('.ffz-channel-selector-outer') === null && Date.now() > this.hostMenuBuffer) {
+ if (!event || ! this.hostMenu || event && event.target && event.target.closest('.ffz-channel-selector-outer') === null && Date.now() > this.hostMenuBuffer) {
this.hostMenuPopper && this.hostMenuPopper.destroy();
this.hostMenu && this.hostMenu.remove();
this.hostMenuPopper = this.hostMenu = undefined;
diff --git a/src/sites/twitch-twilight/modules/directory/game.jsx b/src/sites/twitch-twilight/modules/directory/game.jsx
index 9a48dd24..48da8f76 100644
--- a/src/sites/twitch-twilight/modules/directory/game.jsx
+++ b/src/sites/twitch-twilight/modules/directory/game.jsx
@@ -29,11 +29,11 @@ export default class Game extends SiteModule {
this.apollo.registerModifier('DirectoryPage_Game', GAME_QUERY);
this.apollo.registerModifier('DirectoryPage_Game', res => {
- setTimeout(() =>
+ /*setTimeout(() =>
this.apollo.ensureQuery(
'DirectoryPage_Game',
'data.game.streams.edges.0.node.createdAt'
- ), 500);
+ ), 500);*/
this.modifyStreams(res);
}, false);
@@ -62,10 +62,10 @@ export default class Game extends SiteModule {
updateGameHeader(inst) {
this.updateButtons(inst);
- this.apollo.ensureQuery(
+ /*this.apollo.ensureQuery(
'DirectoryPage_Game',
'data.game.streams.edges.0.node.createdAt'
- );
+ );*/
}
diff --git a/src/sites/twitch-twilight/modules/directory/index.jsx b/src/sites/twitch-twilight/modules/directory/index.jsx
index fcf75c4b..d6c38574 100644
--- a/src/sites/twitch-twilight/modules/directory/index.jsx
+++ b/src/sites/twitch-twilight/modules/directory/index.jsx
@@ -38,6 +38,7 @@ export default class Directory extends SiteModule {
this.inject('site.apollo');
this.inject('site.css_tweaks');
this.inject('site.web_munch');
+ this.inject('site.twitch_data');
this.inject('i18n');
this.inject('settings');
@@ -200,7 +201,7 @@ export default class Directory extends SiteModule {
this.DirectoryVideos.forceUpdate();
})
- this.DirectoryCard.ready(cls => {
+ this.DirectoryCard.ready((cls, instances) => {
//const old_render = cls.prototype.render,
const old_render_iconic = cls.prototype.renderIconicImage,
old_render_titles = cls.prototype.renderTitles;
@@ -272,13 +273,13 @@ export default class Directory extends SiteModule {
// Game Directory Channel Cards
// TODO: Better query handling.
- this.apollo.ensureQuery(
+ /*this.apollo.ensureQuery(
'DirectoryPage_Game',
'data.game.streams.edges.0.node.createdAt'
- );
+ );*/
- //for(const inst of instances)
- // this.updateCard(inst);
+ for(const inst of instances)
+ this.updateCard(inst);
});
this.DirectoryCard.on('update', this.updateCard, this);
@@ -373,12 +374,33 @@ export default class Directory extends SiteModule {
updateUptime(inst, created_path) {
const container = this.fine.getChildNode(inst),
card = container && container.querySelector && container.querySelector('.preview-card-overlay'),
- setting = this.settings.get('directory.uptime'),
- created_at = inst.props && inst.props.createdAt || get(created_path, inst),
- up_since = created_at && new Date(created_at),
+ setting = this.settings.get('directory.uptime');
+
+ if ( ! card || setting === 0 || ! inst.props || inst.props.viewCount || inst.props.animatedImageProps )
+ return this.clearUptime(inst);
+
+ let created_at = inst.props.createdAt || get(created_path, inst);
+
+ if ( ! created_at ) {
+ if ( inst.ffz_stream_meta === undefined ) {
+ inst.ffz_stream_meta = null;
+ this.twitch_data.getStreamMeta(inst.props.channelId, inst.props.channelLogin).then(data => {
+ inst.ffz_stream_meta = data;
+ this.updateUptime(inst, created_path);
+ });
+ }
+
+ if ( inst.ffz_stream_meta )
+ created_at = inst.ffz_stream_meta.createdAt;
+ }
+
+ if ( ! created_at )
+ return this.clearUptime(inst);
+
+ const up_since = created_at && new Date(created_at),
uptime = up_since && Math.floor((Date.now() - up_since) / 1000) || 0;
- if ( ! card || setting === 0 || uptime < 1 )
+ if ( uptime < 1 )
return this.clearUptime(inst);
const up_text = duration_to_string(uptime, false, false, false, setting === 1);
diff --git a/src/std-components/autocomplete.vue b/src/std-components/autocomplete.vue
index 1b759330..407b4813 100644
--- a/src/std-components/autocomplete.vue
+++ b/src/std-components/autocomplete.vue
@@ -305,6 +305,9 @@ export default {
},
onHome(event) {
+ if ( event.ctrlKey || event.shiftKey || event.altKey )
+ return;
+
if ( ! this.open )
return;
@@ -316,6 +319,9 @@ export default {
},
onEnd(event) {
+ if ( event.ctrlKey || event.shiftKey || event.altKey )
+ return;
+
if ( ! this.open )
return;
@@ -327,6 +333,9 @@ export default {
},
onUp(event) {
+ if ( event.ctrlKey || event.shiftKey || event.altKey )
+ return;
+
if ( ! this.open )
return;
@@ -341,6 +350,9 @@ export default {
},
onDown(event) {
+ if ( event.ctrlKey || event.shiftKey || event.altKey )
+ return;
+
if ( ! this.open )
return;
@@ -363,6 +375,9 @@ export default {
},
onEnter(event) {
+ if ( event.ctrlKey || event.shiftKey || event.altKey )
+ return;
+
if ( ! this.open )
return;
diff --git a/src/utilities/data/stream-fetch.gql b/src/utilities/data/stream-fetch.gql
new file mode 100644
index 00000000..926586fa
--- /dev/null
+++ b/src/utilities/data/stream-fetch.gql
@@ -0,0 +1,10 @@
+query FFZ_StreamFetch($ids: [ID!], $logins: [String!]) {
+ users(ids: $ids, logins: $logins) {
+ id
+ login
+ stream {
+ id
+ createdAt
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/utilities/data/stream-single.gql b/src/utilities/data/stream-single.gql
new file mode 100644
index 00000000..b65dfc97
--- /dev/null
+++ b/src/utilities/data/stream-single.gql
@@ -0,0 +1,9 @@
+query FFZ_SingleStream($id: ID, $login: String) {
+ user(id: $id, login: $login) {
+ id
+ stream {
+ id
+ createdAt
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/utilities/object.js b/src/utilities/object.js
index bdad79f1..9b908548 100644
--- a/src/utilities/object.js
+++ b/src/utilities/object.js
@@ -179,6 +179,8 @@ export function deep_equals(object, other, ignore_undefined = false, seen, other
return false;
if ( typeof object !== 'object' )
return false;
+ if ( (object === null) !== (other === null) )
+ return false;
if ( ! seen )
seen = new Set;
diff --git a/src/utilities/twitch-data.js b/src/utilities/twitch-data.js
index c1023ff0..212f5976 100644
--- a/src/utilities/twitch-data.js
+++ b/src/utilities/twitch-data.js
@@ -18,10 +18,14 @@ export default class TwitchData extends Module {
this.inject('site.apollo');
this.inject('site.web_munch');
+ this._waiting_stream_ids = new Map;
+ this._waiting_stream_logins = new Map;
+
this.tag_cache = new Map;
this._waiting_tags = new Map;
- this._loadTags = debounce(this._loadTags.bind(this), 50);
+ this._loadTags = debounce(this._loadTags, 50);
+ this._loadStreams = debounce(this._loadStreams, 50);
}
queryApollo(query, variables, options) {
@@ -56,7 +60,7 @@ export default class TwitchData extends Module {
return this._search;
const apollo = this.apollo.client,
- core = this.listeners.getCore(),
+ core = this.site.getCore(),
search_module = this.web_munch.getModule('algolia-search'),
SearchClient = search_module && search_module.a;
@@ -121,55 +125,220 @@ export default class TwitchData extends Module {
}
+ // ========================================================================
+ // Stream Up-Type (Uptime and Type, for Directory Purposes)
+ // ========================================================================
+
+ getStreamMeta(id, login) {
+ return new Promise(async (s, f) => {
+ if ( id ) {
+ if ( this._waiting_stream_ids.has(id) )
+ this._waiting_stream_ids.get(id).push([s, f]);
+ else
+ this._waiting_stream_ids.set(id, [[s, f]]);
+ } else if ( login ) {
+ if ( this._waiting_stream_logins.has(login) )
+ this._waiting_stream_logins.get(login).push([s, f]);
+ else
+ this._waiting_stream_logins.set(login, [[s, f]]);
+ } else
+ f('id and login cannot both be null');
+
+ if ( ! this._loading_streams )
+ this._loadStreams();
+ })
+ }
+
+ async _loadStreams() {
+ if ( this._loading_streams )
+ return;
+
+ this._loading_streams = true;
+
+ // Get the first 50... things.
+ const ids = [...this._waiting_stream_ids.keys()].slice(0, 50),
+ remaining = 50 - ids.length,
+ logins = remaining > 0 ? [...this._waiting_stream_logins.keys()].slice(0, remaining) : [];
+
+ let nodes;
+
+ try {
+ const data = await this.queryApollo({
+ query: require('./data/stream-fetch.gql'),
+ variables: {
+ ids: ids.length ? ids : null,
+ logins: logins.length ? logins : null
+ }
+ });
+
+ nodes = get('data.users', data);
+
+ } catch(err) {
+ for(const id of ids) {
+ const promises = this._waiting_stream_ids.get(id);
+ this._waiting_stream_ids.delete(id);
+
+ for(const pair of promises)
+ pair[1](err);
+ }
+
+ for(const login of logins) {
+ const promises = this._waiting_stream_logins.get(login);
+ this._waiting_stream_logins.delete(login);
+
+ for(const pair of promises)
+ pair[1](err);
+ }
+
+ return;
+ }
+
+ const id_set = new Set(ids),
+ login_set = new Set(logins);
+
+ if ( Array.isArray(nodes) )
+ for(const node of nodes) {
+ if ( ! node || ! node.id )
+ continue;
+
+ id_set.delete(node.id);
+ login_set.delete(node.login);
+
+ let promises = this._waiting_stream_ids.get(node.id);
+ if ( promises ) {
+ this._waiting_stream_ids.delete(node.id);
+ for(const pair of promises)
+ pair[0](node.stream);
+ }
+
+ promises = this._waiting_stream_logins.get(node.login);
+ if ( promises ) {
+ this._waiting_stream_logins.delete(node.login);
+ for(const pair of promises)
+ pair[0](node.stream);
+ }
+ }
+
+ for(const id of id_set) {
+ const promises = this._waiting_stream_ids.get(id);
+ if ( promises ) {
+ this._waiting_stream_ids.delete(id);
+ for(const pair of promises)
+ pair[0](null);
+ }
+ }
+
+ for(const login of login_set) {
+ const promises = this._waiting_stream_logins.get(login);
+ if ( promises ) {
+ this._waiting_stream_logins.delete(login);
+ for(const pair of promises)
+ pair[0](null);
+ }
+ }
+
+ this._loading_streams = false;
+
+ if ( this._waiting_stream_ids.size || this._waiting_stream_logins.size )
+ this._loadStreams();
+ }
+
+
// ========================================================================
// Tags
// ========================================================================
+ memorizeTag(node, dispatch = true) {
+ // We want properly formed tags.
+ if ( ! node || ! node.id || ! node.tagName || ! node.localizedName )
+ return;
+
+ let old = null;
+ if ( this.tag_cache.has(node.id) )
+ old = this.tag_cache.get(old);
+
+ const match = node.isLanguageTag && LANGUAGE_MATCHER.exec(node.tagName),
+ lang = match && match[1] || null;
+
+ const new_tag = {
+ id: node.id,
+ value: node.id,
+ is_language: node.isLanguageTag,
+ language: lang,
+ name: node.tagName,
+ label: node.localizedName
+ };
+
+ if ( node.localizedDescription )
+ new_tag.description = node.localizedDescription;
+
+ const tag = old ? Object.assign(old, new_tag) : new_tag;
+ this.tag_cache.set(tag.id, tag);
+
+ if ( dispatch && tag.description && this._waiting_tags.has(tag.id) ) {
+ const promises = this._waiting_tags.get(tag.id);
+ this._waiting_tags.delete(tag.id);
+ for(const pair of promises)
+ pair[0](tag);
+ }
+
+ return tag;
+ }
+
async _loadTags() {
if ( this._loading_tags )
return;
this._loading_tags = true;
- const processing = this._waiting_tags;
- this._waiting_tags = new Map;
+
+ // Get the first 50 tags.
+ const ids = [...this._waiting_tags.keys()].slice(0, 50);
+
+ let nodes
try {
const data = await this.queryApollo(
require('./data/tags-fetch.gql'),
{
- ids: [...processing.keys()]
+ ids
}
);
- const nodes = get('data.contentTags', data);
- if ( Array.isArray(nodes) )
- for(const node of nodes) {
- const tag = {
- id: node.id,
- value: node.id,
- is_language: node.isLanguageTag,
- name: node.tagName,
- label: node.localizedName,
- description: node.localizedDescription
- };
-
- this.tag_cache.set(tag.id, tag);
- const promises = processing.get(tag.id);
- if ( promises )
- for(const pair of promises)
- pair[0](tag);
-
- promises.delete(tag.id);
- }
-
- for(const promises of processing.values())
- for(const pair of promises)
- pair[0](null);
+ nodes = get('data.contentTags', data);
} catch(err) {
- for(const promises of processing.values())
+ for(const id of ids) {
+ const promises = this._waiting_tags.get(id);
+ this._waiting_tags.delete(id);
+
for(const pair of promises)
pair[1](err);
+ }
+
+ return;
+ }
+
+ const id_set = new Set(ids);
+
+ if ( Array.isArray(nodes) )
+ for(const node of nodes) {
+ const tag = this.memorizeTag(node, false),
+ promises = this._waiting_tags.get(tag.id);
+
+ this._waiting_tags.delete(tag.id);
+ id_set.delete(tag.id);
+
+ if ( promises )
+ for(const pair of promises)
+ pair[0](tag);
+ }
+
+ for(const id of id_set) {
+ const promises = this._waiting_tags.get(id);
+ this._waiting_tags.delete(id);
+
+ for(const pair of promises)
+ pair[0](null);
}
this._loading_tags = false;
@@ -179,6 +348,10 @@ export default class TwitchData extends Module {
}
getTag(id, want_description = false) {
+ // Make sure we weren't accidentally handed a tag object.
+ if ( id && id.id )
+ id = id.id;
+
if ( this.tag_cache.has(id) ) {
const out = this.tag_cache.get(id);
if ( out && (out.description || ! want_description) )
@@ -197,6 +370,10 @@ export default class TwitchData extends Module {
}
getTagImmediate(id, callback, want_description = false) {
+ // Make sure we weren't accidentally handed a tag object.
+ if ( id && id.id )
+ id = id.id;
+
let out = null;
if ( this.tag_cache.has(id) )
out = this.tag_cache.get(id);
@@ -223,17 +400,7 @@ export default class TwitchData extends Module {
continue;
seen.add(node.id);
- const tag = {
- id: node.id,
- value: node.id,
- is_language: node.isLanguageTag,
- name: node.tagName,
- label: node.localizedName,
- description: node.localizedDescription
- };
-
- this.tag_cache.set(tag.id, tag);
- out.push(tag);
+ out.push(this.memorizeTag(node));
}
return out;
@@ -258,46 +425,89 @@ export default class TwitchData extends Module {
return out;
}
- async getMatchingTags(query, locale) {
+ async getMatchingTags(query, locale, category = null) {
if ( ! locale )
locale = this.locale;
- const data = await this.searchClient.queryForType(
- 'tag', query, generateUUID(), {
- hitsPerPage: 100,
- facetFilters: [
+ locale = locale.toLowerCase();
- ],
- restrictSearchableAttributes: [
- `localizations.${locale}`,
- 'tag_name'
- ]
- }
- );
+ let nodes;
+
+ if ( category ) {
+ const data = await this.searchClient.queryForType(
+ 'stream_tag', query, generateUUID(), {
+ hitsPerPage: 100,
+ faceFilters: [
+ `category_id:${category}`
+ ],
+ restrictSearchableAttributes: [
+ `localizations.${locale}`,
+ 'tag_name'
+ ]
+ }
+ );
+
+ nodes = get('streamTags.hits', data);
+
+ } else {
+ const data = await this.searchClient.queryForType(
+ 'tag', query, generateUUID(), {
+ hitsPerPage: 100,
+ facetFilters: [
+ ['tag_scope:SCOPE_ALL', 'tag_scope:SCOPE_CATEGORY']
+ ],
+ restrictSearchableAttributes: [
+ `localizations.${locale}`,
+ 'tag_name'
+ ]
+ }
+ );
+
+ nodes = get('tags.hits', data);
+ }
- const nodes = get('streamTags.hits', data);
if ( ! Array.isArray(nodes) )
return [];
const out = [], seen = new Set;
for(const node of nodes) {
- if ( ! node || seen.has(node.tag_id) )
+ const tag_id = node.tag_id || node.objectID;
+ if ( ! node || seen.has(tag_id) )
continue;
- seen.add(node.tag_id);
- if ( ! this.tag_cache.has(node.tag_id) ) {
+ seen.add(tag_id);
+ if ( ! this.tag_cache.has(tag_id) ) {
+ const match = node.tag_name && LANGUAGE_MATCHER.exec(node.tag_name),
+ lang = match && match[1] || null;
+
const tag = {
- id: node.tag_id,
- value: node.tag_id,
- is_language: node.tag_name && LANGUAGE_MATCHER.test(node.tag_name),
+ id: tag_id,
+ value: tag_id,
+ is_language: lang != null,
+ language: lang,
label: node.localizations && (node.localizations[locale] || node.localizations['en-us']) || node.tag_name
};
- this.tag_cache.set(tag.id);
+ if ( node.description_localizations ) {
+ const desc = node.description_localizations[locale] || node.description_localizations['en-us'];
+ if ( desc )
+ tag.description = desc;
+ }
+
+ this.tag_cache.set(tag.id, tag);
out.push(tag);
} else {
- out.push(this.tag_cache.get(node.tag_id));
+ const tag = this.tag_cache.get(tag_id);
+ if ( ! tag.description && node.description_localizations ) {
+ const desc = node.description_localizations[locale] || node.description_localizations['en-us'];
+ if ( desc ) {
+ tag.description = desc;
+ this.tag_cache.set(tag.id, tag);
+ }
+ }
+
+ out.push(tag);
}
}
diff --git a/styles/icons.scss b/styles/icons.scss
index dce59a1e..cce8b72d 100644
--- a/styles/icons.scss
+++ b/styles/icons.scss
@@ -128,6 +128,8 @@
.ffz-i-link-ext:before { content: '\f08e'; } /* '' */
.ffz-i-twitter:before { content: '\f099'; } /* '' */
.ffz-i-github:before { content: '\f09b'; } /* '' */
+.ffz-i-sort-down:before { content: '\f0dd'; } /* '' */
+.ffz-i-sort-up:before { content: '\f0de'; } /* '' */
.ffz-i-gauge:before { content: '\f0e4'; } /* '' */
.ffz-i-download-cloud:before { content: '\f0ed'; } /* '' */
.ffz-i-upload-cloud:before { content: '\f0ee'; } /* '' */
@@ -135,6 +137,8 @@
.ffz-i-keyboard:before { content: '\f11c'; } /* '' */
.ffz-i-calendar-empty:before { content: '\f133'; } /* '' */
.ffz-i-ellipsis-vert:before { content: '\f142'; } /* '' */
+.ffz-i-sort-alt-up:before { content: '\f160'; } /* '' */
+.ffz-i-sort-alt-down:before { content: '\f161'; } /* '' */
.ffz-i-language:before { content: '\f1ab'; } /* '' */
.ffz-i-twitch:before { content: '\f1e8'; } /* '' */
.ffz-i-bell-off:before { content: '\f1f7'; } /* '' */
@@ -146,7 +150,6 @@
.ffz-i-window-restore:before { content: '\f2d2'; } /* '' */
.ffz-i-window-close:before { content: '\f2d3'; } /* '' */
-
.ffz-i-pd-1:before { margin-right: 1rem }
.ffz-i-pd-2:before { margin-right: 2rem }
.ffz-i-pd-3:before { margin-right: 3rem }
\ No newline at end of file